[표 1] 본 수업에 사용될 주요 함수 목록
함수 출처 패키지 역할 주요 논항 및 예시
map_dfr() / map() tidyverse(purrr) 리스트의 각 요소에 함수를 적용하고 결과를(DF/리스트로) 반환 map(charvec, ~kiwi_tokenizer_single(.x, kiwi_analyzer))
corpus() quanteda 텍스트 데이터와 메타데이터를 말뭉치(corpus) 객체로 변환 corpus(데이터, text_field = “텍스트열”, docid_field = “ID열”)
docvars() quanteda 말뭉치 객체에 메타데이터(정답 레이블 등)를 저장/호출 docvars(말뭉치, “변수명”) <- 값
dfm() quanteda 토큰화 결과를 문서-단어 행렬(DFM)로 변환 dfm(토큰_객체)
dfm_tfidf() quanteda DFM에 TF-IDF 가중치를 적용하여 단어 중요도 보정 dfm_tfidf(DFM_객체)
pam() cluster K-Medoids 군집화(PAM 알고리즘) 실행 pam(숫자_행렬, k = 3)
fviz_nbclust() factoextra 최적의 군집 수(K)를 실루엣 계수 등으로 시각화 fviz_nbclust(데이터, FUNcluster = pam, method = “silhouette”)
fviz_cluster() factoextra 군집화 결과를 2D 평면에 시각화 fviz_cluster(pam_결과_객체)
table() Base R 모델 예측과 실제 정답을 비교하는 교차표(혼동행렬) 생성 table(True = 실제값, Model = 예측값)
PCA() FactoMineR 주성분 분석(PCA) 실행 PCA(숫자_행렬, scale.unit = TRUE, graph = FALSE)
fviz_eig() factoextra PCA의 Scree Plot을 시각화(각 PC의 분산 설명력) fviz_eig(PCA_결과_객체)
fviz_contrib() factoextra PCA 축(PC)에 기여한 변수(단어)를 시각화(축 명명용) fviz_contrib(PCA_결과, choice = “var”, axes = 1)
fviz_pca_ind() / fviz_pca_var() factoextra PCA 공간에 개별 관측치(문서) 또는 변수(단어)를 시각화 fviz_pca_ind(PCA_결과, habillage = 정답_팩터)

1. 수업 목표 및 개요

(1) 수업목표

  • 비지도학습(군집화, 차원축소)의 핵심원리를 이해하고 지도학습과의 차이점을 설명할 수 있음.
  • 원시 텍스트(JSON)를 전문 형태소 분석기(kiwipiepy)로 토큰화하고, quanteda를 사용해 TF-IDF DFM(숫자 행렬)으로 변환하는 전 과정을 수행할 수 있음.
  • K-Means와 K-Medoids(pam())의 원리와 장단점을 비교 설명할 수 있으며, Silhouette 계수를 활용해 객관적인 K(군집 수)를 선택할 수 있음.
  • PCA(principal component analysis)의 원리를 이해하고(‘직교’, ‘분산 보존’, ‘가중치 조합’, ‘고윳값 분해’), 기여도(Contribution)와 Cos2(표현품질) 지표를 바탕으로 차원축소 결과를 해석하고 명명할 수 있음.
  • 비지도학습의 결과를 실제 레이블과 비교하여 모델의 성능을 평가하는 방법을 적용할 수 있음.

(2) 수업개요

  • 도입: 왜 NLP에서 비지도학습인가?(quanteda의 필요성).
  • 1단계: 텍스트 전처리 및 벡터화(TF-IDF DFM 구축 및 DFM 트리밍).
  • 2단계: 문서 군집화(K-Medoids)(이론 및 실습, Silhouette, 결과해석).
  • 3단계: 차원축소(PCA)(이론 및 실습, 기여도/Cos2, 결과해석).
  • 요약 및 심화적용.

2. 도입: 왜 NLP에서 비지도학습인가?

  • 복습: 지도학습(supervised learning) in NLP
    • 정답(label)이 있는 텍스트 데이터 활용([EX] 긍정/부정 레이블).
    • 한계: 라벨링된 데이터(정답지)는 매우 비싸고(high cost), 구축에 오랜 시간이 걸리며, 특정 도메인에 한정됨.
  • 비지도학습(unsupervised learning) in NLP
    • 정의: ‘정답(label)이 없는’ 대규모 원시 텍스트(raw text)에서 숨겨진 패턴(주제)이나 내재된 의미구조를 탐색함.
    • 목표: ‘예측’이 아닌 ’발견(discovery)’과 ’요약(summarization)’.
      1. 문서 군집화(document clustering)
      • 설명: 비슷한 주제의 문서들끼리 묶기.
      • 예시: 수만 건의 대화 로그를 ‘AS 문의’, ‘결제문의’, ‘환불요청’ 그룹으로 자동분류.
      1. 차원축소(PCA)
      • 설명: 수만 개의 ‘단어’ 변수를 2-3개의 ’핵심 의미축(semantic axis)’으로 요약.
      • 예시: PC1 = ‘불만/항의’ 축, PC2 = ‘단순 정보문의’ 축.
  • 핵심도구: quanteda 패키지의 필요성
    • 비지도학습 알고리즘(K-Means, PCA)은 ’숫자 행렬’만 입력으로 받음. 따라서 텍스트를 숫자로 바꿔야 함.
    • tidytext vs. quanteda:
      • tidytext: 직관적이며 tidyverse와 잘 호환되지만, 대규모 데이터 처리 시 매우 느림(R 메모리 기반).
      • quanteda: 텍스트 분석 전용으로 설계되어 C++ 기반으로 작동. 매우 빠르고, 메모리 효율적이며, 대용량 텍스트(수십만 건) 처리에 적합함.
    • 결론: 실제 연구 및 대규모 분석을 위해서는 quanteda로 DFM(Document-Feature Matrix)을 구축하는 것이 표준임.

3. 1단계: 텍스트 전처리 및 벡터화

  • 목표: AI Hub “용도별 목적대화” JSON 데이터셋을 kiwipiepyquanteda 패키지를 사용해 전문적으로 토큰화하고, TF-IDF DFM(DTM)으로 변환함.
  • 데이터 예제: 구글 드라이브에서 다운받기.

(1) 실습: quanteda로 TF-IDF DFM 만들기

1) 패키지 설치

# 패키지 설치
# ----------------------------------------------------
# cluster: PAM(K-Medoids), Silhouette 등 표준 군집화 도구.
# FactoMineR: PCA 및 해석을 위한 강력한 도구.
# factoextra: cluster, FactoMineR로 얻은 결과를 ggplot2로 시각화.
# ----------------------------------------------------
install.packages(c("cluster", "FactoMineR", "factoextra"))

2) 패키지 로드

# 패키지 로드
library(tidyverse) 
library(quanteda) # 텍스트 분석 핵심 패키지(corpus, dfm, dfm_tfidf, tokens_custom 등).
library(jsonlite) # fromJSON() 함수로 JSON 파일 로드.
library(reticulate) # R에서 Python을 사용하기 위한 패키지.
library(furrr) # purrr와 동일한 기능을 제공하되 병렬처리가 가능하도록 해주는 패키지.
library(stringi) # 문자열을 UTF-8 형식으로 포맷해주는 함수를 탑재한 패키지.
library(cluster) # 군집화(K-Medoids) 기능을 제공해주는 패키지.
library(FactoMineR) # 주성분 분석(PCA) 기능을 제공해주는 패키지.
library(factoextra) # 군집화 및 주성분 분석 관련 시각화 기능을 제공해주는 패키지.

3) 토큰화 함수 정의

  • 토큰 벡터를 반환하는 토큰화 함수 정의: 지도학습 수업에서 사용한 토큰화 함수와 동일.
# Conda 가상환경 지정
# 이 가상환경에는 한국어 형태소 분석기 'kiwipiepy'가 미리 설치되어 있어야 함.
use_condaenv("my_env")

# Python 형태소 분석기 모듈 불러오기
# 'kiwipiepy' 라이브러리를 Python에서 import하여 'kiwi_analyzer'라는 R 객체에 할당.
# 이제 'kiwi_analyzer' 객체를 통해 R에서 'kiwipiepy'의 함수들을 호출할 수 있게 됨.
kiwi <- import("kiwipiepy")$Kiwi()

# [1] 단일 텍스트 처리 함수 정의
# 텍스트 1개를 입력받아 전처리 후 토큰화된 벡터를 반환.
kiwi_tokenizer <- function(text) {
  
  # Python 객체의 $analyze() 메서드를 호출하여 형태소 분석 실행
  results <- kiwi$analyze(text)
  
  #(오류 방지) kiwipiepy가 빈 결과를 반환할 경우, 빈 문자열 벡터를 반환
  if(length(results) == 0 || length(results[[1]]) == 0) {
    return(character(0))
  }

  # map_dfr: purrr 함수. 리스트(results[[1]][[1]])의 각 요소를(.x) 순회하며
  # tibble(morph = .x$form, pos = .x$tag)을 실행하고, 
  # 그 결과를 모두 합쳐 하나의 tibble로 만듦.
  tagged_tokens <- map_dfr(results[[1]][[1]], ~tibble( 
    morph = .x$form, # 형태소
    pos = .x$tag     # 품사
  ))
  
  # "형태소/품사" 기반의 도메인 특화 불용어 사전 정의
  stop_words_pos <- c("이/VCP", "것/NNB", "곳/NNG", "곳/NNB", "하/VX", "하/VV", "수/NNB", "되/VV", "듯/NNB",
                      "하/XSA", "들/XSN", "되/XSA", "되/XSV", "하/XSV", "이/VCP", "이/VV", "적/XSN",
                      "님/NNG", "고객/NNG", "상담사/NNG", "상담원/NNG", "저희/NP", "예/IC", "지금/MAG",
                      "그/MM", "보/VV", "보/VX")
  
  # 토큰 필터링하기
  token_vector <- tagged_tokens %>%
    # filter()와 정규표현식(str_detect)으로 불필요한 품사 제거
    filter(!str_detect(pos, "^J"), # J로 시작하는 조사(JKS, JKC...) 제거.
           !str_detect(pos, "^E"), # E로 시작하는 어미(EF, EP...) 제거.
           !str_detect(pos, "SF|SP|SS"), # 구두점(마침표, 쉼표 등) 제거.
           !str_detect(morph, "\\#[ㄱ-ㅎ가-힣]{1,20}|반갑습니다") # 개인신상을 특정할 수 있는 고유명사 대신 붙은 "#"이 앞에 있는 어절과 인사 제거.
    ) %>%
    # mutate(): "형태소/품사" 형태의 새 열 생성([EX] "배송/NNG")
    mutate(tagged_morph = str_c(morph, "/", pos)) %>% 
    # 위에서 정의한 불용어 사전에 포함되지 않는(! %in%) 토큰만 남김
    filter(!tagged_morph %in% stop_words_pos) %>%
    # pull(): tibble의 'morph' 열만 추출하여 R의 기본 문자열 '벡터'로 변환
    pull(morph) 
  
  # 최종 정제된 토큰 벡터 반환
  return(token_vector)
}

4) AI 허브 용도별 목적대화 JSON 데이터셋 R로 불러들이기

  • AI 허브 용도별 목적대화 데이터셋 구글 드라이브에서 다운로드하기.
library(googledrive) # 구글 드라이브에 저장된 파일 공유 링크 통해 다운로드하는 데 필요한 패키지 불러오기.

# 인증 해제하기(공유된 공개 파일일 경우).
drive_deauth()

# 다운로드할 파일을 저장할 폴더 만들기.
dir.create("ML_text/unsupervised", recursive = T)

# "ML_text_JSON.zip" 압축파일에 대한 구글 드라이브 공유 링크 고유번호 저장하기.
ml_url_id <- "1adY41zg_s05N-k9ndlK34M4O8c_PZr1a"

# 내보낼 로컬 경로 지정하기.
local_path <- "ML_text/unsupervised/unsupervised_dataset.zip"

# 파일 다운로드하기.
drive_download(as_id(ml_url_id), path = local_path, overwrite = TRUE)

# 다운로드한 압축파일 ML_text/unsupervised 폴더에 압축 풀기.
unzip(local_path, exdir = "ML_text/unsupervised") 
  • AI 허브 용도별 목적대화 데이터셋 하나의 티블로 만들기.
# JSON 파일 목록 불러오기.
unsupervised.list <- list.files(path = "ML_text/unsupervised", pattern = "json$", recursive = T)
unsupervised.list.1 <- str_c("ML_text/unsupervised/", unsupervised.list)

# 문제가 있는 파일 걸러내기 & 필요한 칼럼만 추출하기.
safe_read_json <- function(file) {
  tryCatch({
    json <- fromJSON(file)
    tibble(
      file = basename(file),
      identifier = json$dataset$identifier,
      doc_id = json$dataset$name,
      true_topic = json$info$annotations$subject,
      text = json$info$annotations$text,
      lines = json$info$annotations$lines
    )
  }, error = function(e) {
    message(" Error in file: ", file)
    return(NULL)
  })
}

# map_dfr 함수를 사용하여 목록 내 파일 일괄적으로 읽어들이기.
unsupervised.list.2 <- map_dfr(unsupervised.list.1, safe_read_json)

# lines 슬롯의 화자(speaker)별 화행(speechAct) 정보 수집하기.
speaker_act_summary <- unsupervised.list.2$lines %>%
  map_chr(~ .x %>%
            as_tibble() %>% # lines 슬롯을 티블로 변환하기. 
            mutate(speaker_act_match = str_c(speaker$id, "_", speechAct), # speaker(화자)와 speechAct(화행)을 결합하기([EX] A_질문하기, B_부탁하기).
                   speaker_act_match = str_remove_all(speaker_act_match, " ")) %>% # 화행 기술 내 띄어쓰기 모두 제거하기.
            summarise(speaker_act_match = str_c(speaker_act_match, collapse = " ")) %>% # 하나의 대화에 포함된 모든 말 차례의 화행을 띄어쓰기 단위로 통합하기.
            pull(speaker_act_match) # 하나의 대화에 포함된 모든 화행을 벡터 형식으로 반출하기.
  ) %>%
  as_tibble() %>% # 대화별 화행 통합 정보 벡터를 티블로 변환하기.
  rename(speech_act = value) # value라는 제목의 열 제목을 speech_act로 바꾸기.

# unsupervised.list.2 티블과 speaker_act_summary 티블 열 단위로 결합하기.
unsupervised.list.2.1 <- bind_cols(unsupervised.list.2, speaker_act_summary)

# 5개 주제에 대해 각 1000개의 대화를 랜덤 추출하기.
set.seed(123)
unsupervised.list.3 <- unsupervised.list.2.1 %>%
  filter(true_topic %in% c("AS문의", "제품/사용문의", "배송", "주문/결제", "환불/반품/교환")) %>%
  group_by(true_topic) %>%
  sample_n(1000) %>%
  ungroup()

# 필요한 칼럼만 취하기.
unsupervised.dataset <- unsupervised.list.3  %>%
  select(doc_id, true_topic, text, speech_act) # 문서 식별자, 실제 대화주제, 대화 텍스트, 말 차례별 화행 정보.
  • unsupervised.dataset의 텍스트 열 토큰화하기.
unsupervised.dataset_tokenized <- unsupervised.dataset %>%
  mutate(text = stri_enc_toutf8(text), # 한글이 깨지지 않도록 UTF-8 형식으로 텍스트 열 포맷하기.
         text = future_map(text, kiwi_tokenizer), # 병렬처리를 가능하게 해주는 furrr 패키지의 future_map() 함수 사용하여 text 열에 대해 kiwi_tokenizer() 함수 적용하기.
         text = future_map_chr(text, ~str_c(.x, collapse = " ")) # 병렬처리를 가능하게 해주는 furrr 패키지의 future_map_chr() 함수 사용하여 text 열 벡터에 대해 띄어쓰기 단위로 모든 토큰 이어주기.
         ) 
save(unsupervised.dataset_tokenized, file="unsupervised.dataset_tokenized.rda")
  • 각종 데이터셋 및 모델링 결과 다운로드하기.
library(googledrive) # 구글 드라이브에 저장된 파일 공유 링크 통해 다운로드하는 데 필요한 패키지 불러오기.

# 인증 해제하기(공유된 공개 파일일 경우).
drive_deauth()

# "unsupervised_learning.zip" 압축파일에 대한 구글 드라이브 공유 링크 고유번호 저장하기.
ml_url_id <- "1LqJIi9WSzrYV4qL-4bUgI9oRgfNio46E"

# 내보낼 로컬 경로 지정하기.
local_path <- "unsupervised_learning.zip"

# 파일 다운로드하기.
drive_download(as_id(ml_url_id), path = local_path, overwrite = TRUE)

# 다운로드한 압축파일 워킹 디렉토리에 압축 풀기.
unzip(local_path, exdir = ".") 

5) quanteda로 DFM 생성하기

A. Corpus 생성

  • docvars()의 핵심기능: quantedacorpus 객체에 ‘문서변수(document variables)’, 즉 메타데이터를 할당하거나(<-) 추출함.
  • 수업에서의 활용
    • 할당
      • 예시: docvars(uns_corpus, “true_topic”) \(\leftarrow\) unsupervised.dataset$true_topic.
      • 기능: 원본 데이터셋의 다섯 가지 ‘정답 주제’(true_topic)를 corpus 객체 안에 저장함.
    • 추출
      • 예시: true_topics \(\leftarrow\) docvars(uns_corpus, “true_topic”).
      • 기능: 저장해둔 ’정답’을 다시 꺼내 비지도학습 결과(군집, PCA)를 ’검증’하는 데 사용함.
  • 주요 사용법
    • docvars(코퍼스 객체): 모든 문서변수를 데이터프레임 형태로 추출함.
    • docvars(코퍼스 객체, "변수명"): 특정 “변수명”에 해당하는 벡터를 추출함.
    • docvars(코퍼스 객체, "새 변수명") <- 벡터: 코퍼스에 “새 변수명”으로 벡터를 할당함.
load("unsupervised.dataset_tokenized.rda")

# Corpus: 텍스트 분석의 시작점. 텍스트와 메타데이터를 함께 관리하는 객체.
uns_corpus <- corpus(
    unsupervised.dataset_tokenized, # 입력 데이터(tibble 또는 data.frame)
    docid_field = "doc_id", # 문서 ID로 사용할 열 이름
    text_field = "text" # 실제 텍스트 내용이 담긴 열 이름
)

# 메타데이터(정답)
# docvars()(document variables) 함수로 Corpus에 메타데이터(정답 주제)를 저장.
# 나중에 모델 검증용으로 사용할 예정.
docvars(uns_corpus, "true_topic") <- unsupervised.dataset_tokenized$true_topic

B. Tokens 생성

  • tokens(): Corpus 객체를 토큰화해주기.
# tokens 생성
uns_tokens <- tokens(uns_corpus, remove_punct = TRUE) # quanteda Corpus 객체 투입 & 최소한의 토큰화 수행.

C. DFM 생성

  • dfm(): 토큰화가 완료된 ‘tokens’ 객체를 DFM(문서-단어 행렬)으로 만들기.
# DFM(문서-단어 행렬) 생성
# 전처리/토큰화가 완료된 'tokens' 객체를 dfm() 함수에 전달하여 DFM 생성.
uns_dfm <- dfm(uns_tokens)

# 말뭉치 등장 빈도 최저한도 설정
uns_dfm_trimmed_min <- dfm_trim(uns_dfm, 
                                min_termfreq = 5, # 전체에서 최소 5회 이상 등장.
                                min_docfreq = 2, # 최소 2개 문서 이상 등장.
                                docfreq_type = "count") 

# 문서의 성격을 고려하여 trimming하기
# 'A', 'B', '고객', '상담사', '#입니다' 같은 불용어들을 걸러주기.
uns_dfm_trimmed_final <- dfm_trim(
  uns_dfm_trimmed_min, 
  # 전체 문서의 70%를 초과하여 등장하는 단어는 제거.
  # (너무 보편적이어서 IDF가 0에 가까워지는 단어들을 제거)
  max_docfreq = 0.70, # 70% 이상 문서에 등장한 단어 제거.
  docfreq_type = "prop"
  )

# DFM 객체 확인
uns_dfm_trimmed_final
## Document-feature matrix of: 5,000 documents, 2,684 features (98.48% sparse) and 2 docvars.
##                           features
## docs                       이어폰 블루투스 연결 잘 안 혹시 어떤 기종 사용 중
##   s1_20211104_0001_3642_01      1        2    4  2  3    2    1    1    2  1
##   s1_20210914_0001_0729_01      0        0    0  2  5    3    0    0    1  0
##   s1_20210913_0001_0393_01      0        0    0  0  0    0    0    0    0  0
##   s1_20211008_0006_0076_01      0        0    0  0  0    0    0    0    0  0
##   s1_20210916_0002_0099_01      0        0    0  1  1    0    0    0    0  0
##   s1_20210916_0002_0211_01      0        0    0  0  0    0    0    0    0  0
## [ reached max_ndoc ... 4,994 more documents, reached max_nfeat ... 2,674 more features ]

D. TF-IDF 가중치 적용

가. TF-DIF 가중치 행렬 생성
  • TF-IDF 가중치 적용의 필요성
    • 단순빈도 기반 DFM을 그냥 사용하면 모든 문서에 자주 나오는 단어들([EX] 불용어)이 분석을 지배하게 됨.
    • TF-IDF 가중치는 이러한 공통단어(IDF가 낮음)의 영향력은 줄이고, 특정 문서 그룹에서만 집중적으로 등장하는 ‘핵심/주제어’(IDF가 높음)의 영향력을 높여줌.
    • 이는 K-Medoids나 PCA가 문서 간의 ‘진짜’ 의미 차이(주제 차이)를 더 잘 포착하도록 보정해주는 필수과정임.
  • 기본행렬(matrix) 포맷으로 변환하기
    • cluster, FactoMineR 등 외부 패키지는 DFM 객체를 직접 받지 못하는 경우가 많음. \(\Rightarrow\) as.matrix() 함수를 사용해 R의 기본행렬(matrix) 형태로 변환.
    • 이제 이 tfidf_matrix가 K-Medoids, PCA가 입력받을 우리의 최종 숫자 데이터임!
# TF-IDF 변환
# dfm_tfidf() 함수로 DFM을 TF-IDF 가중치 행렬로 변환.
tfidf_dfm <- dfm_tfidf(uns_dfm_trimmed_final)

# 기본행렬(matrix) 포맷으로 변환
# 'cluster', 'FactoMineR' 등 외부 패키지는 DFM 객체를 직접 받지 못하는 경우가 많음.
# as.matrix()를 사용해 R의 기본행렬(matrix) 형태로 변환함.
tfidf_matrix <- as.matrix(tfidf_dfm)
save(tfidf_matrix, file="tfidf_matrix.rda")

# scaling을 통해 단어들의 TF-IDF 값들을 표준화해줌.
tfidf_matrix_scaled <- scale(as.matrix(tfidf_dfm))
save(tfidf_matrix_scaled, file="tfidf_matrix_scaled.rda")
나. 화행 정보 행렬 생성
  • 나중에 대화별 화행 정보가 대화 주제를 분류하는 데 영향을 미칠 가능성을 확인하기 위해 미리 화행 정보를 행렬 형식으로 정리해두기.
# 'speech_act' 열을 공백(" ") 기준으로 분리하여 개별 행으로 만들기.
speech_acts_long <- unsupervised.dataset_tokenized %>%
  # ([EX] "A_인사하기 B_진술하기" -> "A_인사하기" [1행], "B_진술하기" [2행])
  separate_rows(speech_act, sep = " ") %>%
  # 분리 과정에서 생길 수 있는 빈 문자열 행은 제거하기.
  filter(speech_act != "")

# --- [ max_docfreq = 0.7 룰 적용 시작 ] --- 

# 전체 문서의 총 수 계산
n_total_docs <- n_distinct(speech_acts_long$doc_id)

# 각 화행(speechAct)이 몇 개의 '고유한' 문서에 등장했는지(docfreq) 계산
speech_acts_docfreq <- speech_acts_long %>%
  # doc_id와 speech_act가 중복된 행을 하나로 합치기(문서당 1회만 카운트).
  distinct(doc_id, speech_act) %>%
  # speech_act를 기준으로 카운트.
  count(speech_act, name = "docfreq")

# 전체 문서의 70%를 '초과'하여 등장하는 '너무 흔한 화행' 목록 만들기
common_speech_acts <- speech_acts_docfreq %>%
  # [EX] A_진술하기, A_인사하기 등이 여기에 해당될 것.
  filter(docfreq / n_total_docs > 0.7) %>%
  pull(speech_act) # 'A_진술하기' 같은 화행 이름만 벡터로 추출.

# 'speech_acts_long' 원본에서 '너무 흔한 화행' 제거
speech_acts_filtered <- speech_acts_long %>%
  filter(!speech_act %in% common_speech_acts)

# --- [ max_docfreq = 0.7 룰 적용 종료 ] --- 

# 필터링된 데이터를 'doc_id'와 'speech_act' 기준으로 카운트.
speech_acts_counted_filtered <- speech_acts_filtered %>%
  count(doc_id, speech_act)

# pivot_wider() 함수를 사용해 DFM(문서-용어 행렬) 형태로 펼치기
speech_act_dfm_filtered <- speech_acts_counted_filtered %>%
  pivot_wider(
    names_from = speech_act, # 새 컬럼의 이름이 될 값들.
    values_from = n, # 셀에 채워질 값
    values_fill = 0  # 해당 화행이 없는 문서는 0으로 채움
  ) %>%
  select(-`A_N/A`, -`B_N/A`) # 화행 정보가 없는 열은 제거.

# (최종) TF-IDF 행렬과 cbind()하기 좋도록 matrix 객체로 변환
speech_act_matrix <- speech_act_dfm_filtered %>%
  # 'doc_id' 컬럼을 행 이름으로 변환.
  column_to_rownames(var = "doc_id") %>%
  # 데이터프레임을 행렬로 변환.
  as.matrix()

# 흔한 화행([EX] 'A_진술하기' 등)이 빠졌는지 확인
colnames(speech_act_matrix) 
##  [1] "A_부탁하기"                       "B_감사하기"                      
##  [3] "B_인사하기"                       "A_감사하기"                      
##  [5] "B_주장하기"                       "A_약속하기(개인적수준의서약)"    
##  [7] "B_명령하기/요구하기"              "B_부탁하기"                      
##  [9] "A_명령하기/요구하기"              "B_약속하기(개인적수준의서약)"    
## [11] "A_부정감정표현하기(비난하기포함)" "B_긍정감정표현하기(칭찬하기포함)"
## [13] "A_사과하기"                       "B_부정감정표현하기(비난하기포함)"
## [15] "B_사과하기"                       "A_반박하기"                      
## [17] "B_반박하기"                       "A_주장하기"                      
## [19] "A_충고하기"                       "A_거절하기"                      
## [21] "B_거절하기"                       "A_긍정감정표현하기(칭찬하기포함)"
## [23] "B_충고하기"
# 화행 정보 행렬 행 수 확인
nrow(speech_act_matrix) # 4730행(5000행이 안 됨).
## [1] 4730
  • 짧아진 화행 정보 행렬에 맞춰 tfidf_matrix 행 수와 true_topic 개수도 줄이기.
load("tfidf_matrix.rda")

# tfidf_matrix를 티블로 변환하고 문서 식별자 행 이름을 rowname 열로 추가하기.
tfidf_matrix.tb <- tfidf_matrix %>%
  as.data.frame() %>%
  rownames_to_column() %>%
  as_tibble()

# speech_act_matrix를 티블로 변환하고 문서 식별자 행 이름을 rowname 열로 추가하기.
speech_act_matrix.tb <- speech_act_matrix %>%
  as.data.frame() %>%
  rownames_to_column() %>%
  as_tibble()

# speech_act_matrix.tb 티블의 문서 식별자 열과 tfidf_matrix.tb 티블을 조인시켜 
# tfidf_matrix.tb 티블을 speech_act_matrix.tb과 동일한 행 수로 맞추기.
tfidf_matrix.tb.1 <- speech_act_matrix.tb[, 1] %>%
  left_join(tfidf_matrix.tb, by = "rowname") %>% # 'rowname' 열을 기준으로 두 티블을 병합하기.
  rename(doc_id = rowname) # 'rowname' 열을 'doc_id'이라는 이름으로 바꾸기.

# speech_act_matrix에 행 수를 맞춘 tfidf_matrix_hybrid 행렬 생성하기.
tfidf_matrix_hybrid <- tfidf_matrix.tb.1 %>%
  # 'doc_id' 열을 행 이름으로 변환.
  column_to_rownames(var = "doc_id") %>%
  # 데이터프레임을 행렬로 변환.
  as.matrix()
nrow(tfidf_matrix_hybrid)
## [1] 4730
# tfidf_matrix_hybrid 행렬의 행 수와 true_topics 개수 일치시키기.
true_topics_hybrid <- tfidf_matrix.tb.1 %>%
  left_join(unsupervised.dataset_tokenized[, 1:2], by ="doc_id") %>% # 'doc_id' 열을 기준으로 tfidf_matrix.tb.1과 unsupervised.dataset_tokenized[ , 1:2] 티블 병합하기.
  pull(true_topic) # true_topic 벡터만 추출하기.

3. 2단계: 문서 군집화(K-Medoids)

  • 목표: TF-IDF 행렬(tfidf_matrix)을 사용해 비슷한 ’주제’의 문서를 K-Medoids(pam())로 묶음.

(1) 이론

  • 문서 군집화(document clustering)란?
    • ‘정답’ 없이, 문서 간의 ‘유사도’(우리의 경우 TF-IDF 벡터 간의 거리)를 기반으로, 비슷한 텍스트들을 하나의 ’군집(cluster)’으로 묶는 비지도학습 기법.
    • 목표: 전체 문서집합의 숨겨진 주제구조를 발견함.
  • NLP 군집화의 어려움: DFM의 희소성(sparsity)
    • 대부분의 DFM에서 행(문서)의 수에 비해 열(단어)의 수는 매우 많음. [EX] 5000개의 문서가 있다면, 수천 개의 단어가 존재!
    • 고차원성(high-dimensionality): 수천 개의 단어는 수천 개의 ’차원’을 의미함.
    • 희소성(sparsity): 하나의 문서는 수천 개 단어 중 고작 100개 정도만 사용함. \(\Rightarrow\) 행렬의 99.5%가 0으로 채워져 있음!
    • 문제점: 이 ‘차원의 저주’ 환경에서는 모든 문서가 서로 멀리 떨어져 있는 것처럼 보여서, ‘가까운’ 문서를 찾는 알고리즘([EX] K-Means)이 잘 작동하기 어려움.

1) K-평균 군집화(K-Means)

  • 개념: 가장 널리 알려진 군집화 알고리즘. K개의 군집을 찾도록 사용자가 지정함.
  • 작동원리
    1. Initialization: K개의 임의의 점을 ’군집중심(centroid)’으로 찍음.
    2. Assignment: 모든 문서는 K개의 중심 중 자신과 가장 ‘가까운’ 중심에 할당됨.
    3. Update: 각 군집에 속한 문서들의 ‘가상적인 평균(mean)’ 지점을 계산하여, 그곳으로 ’군집중심’을 이동시킴.
    4. Repeat: 2, 3단계를 중심점이 더 이상 움직이지 않을 때까지 반복함.
  • K-Means에서 K를 정하는 법: 엘보우 기법(elbow method)
    • 질문: “K를 몇 개로 해야 하는가?”(가장 중요한 질문)
    • 개념: K를 1개, 2개, 3개…로 늘려가면서, 각 K별로 ‘군집 내 총 응집도’(total within-cluster sum of squares, WCSS)를 계산하여 그래프로 그림.
    • WCSS: 각 문서가 자신이 속한 군집의 중심으로부터 얼마나 떨어져 있는지의 총합. 낮을수록 똘똘 뭉쳐 있다고 간주할 수 있음.
    • 해석: K가 늘어날수록 WCSS는 당연히 줄어듦(K=문서 개수 \(\Rightarrow\) WCSS=0). 하지만 그래프가 ’팔꿈치(elbow)’처럼 급격히 꺾이는 지점이 존재함.
    • 이 지점이 ‘K를 더 늘려도 응집도 개선 효과가 크지 않은’ 최적의 K 후보가 됨.
  • NLP에서의 약점
    1. 이상치(outlier)에 취약: DFM의 희소성 때문에 ‘평균’ 계산이 왜곡됨([EX] ‘AS 문의’ 99개와 ‘배송 문의’ 1개가 섞여있을 때, ‘평균’ 중심점은 ‘배송 문의’ 문서 쪽으로 크게 왜곡될 수 있음.
    2. 해석의 어려움: ‘가상의 평균’ 벡터는 실제 존재하는 문서가 아니므로, “1번 군집은 이런 문서입니다”라고 직관적으로 설명하기 어려움.
    3. 주관적 K 선택: 엘보우 기법은 그래프가 꺾이는 지점을 눈으로 찾아야 하므로 매우 주관적임.

[그림 1] 엘보우 기법 예시

2) K-중앙값 군집화(K-Medoids)

  • 개념: K-Means의 약점을 보완하기 위해 제안됨. K-Means가 ’가상의 평균’을 중심(centroid)으로 사용하는 반면, K-Medoids는 ’실제 데이터 포인트’를 중심(medoid)으로 사용함.
  • 작동원리
    1. Initialization: K개의 ’실제 문서’를 임의로 ’군집중심(Medoid)’으로 선택함.
    2. Assignment: 모든 문서는 K개의 중심(Medoid) 중 자신과 가장 ‘가까운’ 중심에 할당됨.
    3. Update: 각 군집 내에서, ’중심이 아닌 문서’가 ’현재 중심문서’를 대체했을 때 전체 군집의 응집도가 더 좋아지는지 테스트함(가장 응집도를 높이는 ’실제 문서’로 중심점을 교체[swap]함).
    4. Repeat: 2, 3단계를 중심점 교체가 더 이상 일어나지 않을 때까지 반복함.
  • K-Medoids에서 K를 정하는 법: 실루엣 계수(Silhouette coefficient)
    • 왜 필요한가? 엘보우 기법의 주관성을 보완하는 ’객관적인 성능지표’임.
    • 실루엣의 정의: “이 군집이 얼마나 ‘잘’ 형성되었는가?”를 -1~+1 사이의 ’객관적 점수’로 정량화함.
    • \(S_i =(b_i - a_i) / \max(a_i, b_i)\)
    • \(a_i\)(응집도, Cohesion): 데이터 \(i\)와 ‘같은 군집’(우리 편)에 속한 다른 점들 간의 평균 거리(낮을수록 똘똘 뭉쳐 있음).
    • \(b_i\)(분리도, Separation): 데이터 \(i\)와 ‘가장 가까운 다른 군집’(남의 편)의 점들 간의 평균 거리(높을수록 잘 분리됨).
    • 해석:
      • \(S \approx +1\): 완벽( \(b_i\)는 크고 \(a_i\)는 0에 가까움).
      • \(S \approx 0\): 두 군집의 경계선에 걸쳐 있음(\(a_i \approx b_i\)).
      • \(S \approx -1\): 최악(잘못된 군집에 할당됨. \(a_i > b_i\)).
    • 활용: K를 2, 3, 4…로 바꿔가며 ’평균 실루엣 계수’를 계산하고, 이 평균점수가 가장 높아지는 K를 최적의 K로 ’객관적’으로 선택함.

[그림 2] K-중앙값 군집화 예시

3) K-Means vs. K-Medoids: NLP를 위한 선택

  • 희소 데이터: K-Means(기본 R)는 빠르지만, K-Medoids(pam())는 TF-IDF 같은 고차원 희소 데이터에 두 가지 큰 강점을 가짐.
    • 강건성(robust): K-Medoids는 ’평균’이 아닌 ’중앙값(실제 데이터)’을 사용하므로, 이상치 문서에 중심점이 왜곡되지 않음.
    • 해석 용이성(interpretable): K-Medoids를 사용하면, “1번 군집의 중심(medoid)은 ‘doc10’(AS 문의 대화)이다”라고 말할 수 있음. 즉 ’doc10’이 1번 군집의 가장 대표적인 문서(archetype)가 됨.
  • 결론: 본 수업에서는 “자연언어처리”의 수월성을 위해 K-Medoids(pam())를 사용함.

(2) cluster 패키지

1) pam()

  • 핵심기능: K-Medoids(partitioning around medoids, PAM) 군집화 알고리즘을 실행함.
  • 수업에서의 활용: K-Means가 ‘가상의 평균’을 중심으로 사용하여 고차원 희소 DFM의 이상치에 취약한 반면, pam()은 ’실제 데이터’(medoid)를 중심으로 사용하므로 더 강건(robust)하고 해석이 용이함. tfidf_matrix를 입력받아 문서군집을 생성함.
  • 주요 논항
    • x: 숫자 행렬 또는 data.frame(본 수업에서는 tfidf_matrix가 입력됨).
    • k: 군집의 수(정수). fviz_nbclust()로 찾은 최적의 K 값을 전달함.
    • metric: 사용할 거리 계산법. 기본값은 "euclidean"(유클리드 거리)임.
    • stand: TRUE로 설정 시, pam() 함수가 내부적으로 데이터를 표준화함.
  • 반환값: pam 객체를 반환함. 이 중 $clustering(각 문서가 할당된 군집번호 벡터)과 $medoids(중심점으로 선택된 문서의 행 이름 또는 인덱스)가 가장 중요함.

(3) factoextra 패키지

1) fviz_nbclust()

  • 핵심기능: “Find Optimal Number of Clusters”(최적의 군집 수 K 찾기)를 시각화함.
  • 수업에서의 활용: 엘보우 기법의 주관성을 극복하고, ’실루엣 계수(Silhouette coefficient)’라는 객관적 지표를 통하여 최적의 K를 찾기 위해 사용함.
  • 주요 논항
    • x: 숫자 행렬. \(\Rightarrow\) 본 수업에서는 tfidf_matrix가 입력됨.
    • FUNcluster: 사용할 군집함수를 지정함([EX] pam, kmeans, hcut 등).
    • method: K를 평가할 방법을 지정함. "silhouette"(본 수업 사용, 객관적 지표), "wss"(엘보우 기법), "gap_stat"(Gap 통계량).
    • k.max: 테스트할 최대 K 값(너무 크면 시간이 오래 걸림).

2) fviz_cluster()

  • 핵심기능: pam() 또는 kmeans() 등으로 생성된 군집화 ’결과’를 2D 평면(PC1, PC2)에 시각화함.
  • 수업에서의 활용: pam_result 객체를 입력받아, 고차원(수천 개 단어) 공간에서 문서들이 어떻게 그룹화되었는지 2D로 시각화하여 직관적으로 파악함(내부적으로 PCA 또는 MDS를 사용해 2D로 자동 축소함).
  • 주요 논항
    • object: 군집결과 객체(본 수업에서는 pam_result가 입력됨).
    • data: 원본 데이터(tfidf_matrix). geom="text"로 문서 ID를 표시할 때 필요할 수 있음.
    • ellipse.type: 군집 주변에 타원을 그리는 방식([EX] "convex"(볼록 껍질), "t", "norm", "euclid").
    • ggtheme: ggplot2 테마([EX] theme_bw(), theme_minimal()).

(4) 실습

1) (지표 기반) 최적의 K 찾기(Silhouette 계수)

# 관련 패키지 로드
library(cluster) # pam() 함수 제공.
library(factoextra) # fviz_nbclust(), fviz_cluster() 시각화 함수 제공.
load("uns_nbclust.rda")

# 최적의 K 탐색
# fviz_nbclust: K-Means, PAM 등 다양한 군집 알고리즘에 대해
# Elbow(wss), Silhouette, Gap-statistic 세 가지 방법으로 최적의 K를 시각화.

uns_nbclust <- fviz_nbclust(
  tfidf_matrix_scaled, # 입력 데이터(숫자 행렬).
  FUNcluster = pam, # 사용할 군집 알고리즘 지정(K-Medoids).
  method = "silhouette", # K 평가 방법(실루엣 계수).
  k.max = 5 # 테스트할 최대 K 값(실제 대화유형이 5개이므로 5로 설정).
  )
uns_nbclust

  • 해석: Silhouette 계수가 가장 높은 지점의 K는 3. 따라서 일단 3을 최적의 군집 수로 설정하고 이후 PAM 알고리즘 실행.

2) pam() 실행 및 시각화

A. K=3일 경우

가. 실습
# PAM 모델 실행
# cluster 패키지의 pam() 함수로 K-Medoids 군집화 실행
pam_result_k3 <- pam(tfidf_matrix, # 입력 데이터(숫자 행렬)
                  k = 3 # 앞서 찾은 최적의 K 값(3개)
                  )

# 군집 할당 결과 확인
# 결과객체의 $clustering 슬롯에 각 문서가 할당된 군집번호가 저장됨.
pam_result_k3$clustering

# 군집 시각화
# fviz_cluster: factoextra의 군집 시각화 함수
# 내부적으로 PCA 또는 MDS를 실행하여 고차원 데이터를 2D로 축소 후 시각화.
uns_cluster_k3 <- fviz_cluster(
  pam_result_k3, # PAM 모델 결과 객체
  data = tfidf_matrix, # 원본 데이터(필수는 아님)
  ellipse.type = "convex", # 군집을 볼록 껍질(convex hull)로 표시
  ggtheme = theme_bw() # 그래프 테마
  )
ggsave("uns_cluster_k3_plot.png", uns_cluster_k3, width = 10, height = 10)
uns_cluster_k3

나. 해석
a. Dim1(x축)과 Dim2(y축)의 정체: “요약된 축”
  • Dim1Dim2가 가리키는 것
    • Dim1: 제1 주성분(PC1).
    • Dim2: 제2 주성분(PC2).
  • fviz_cluster() 함수 pam_result_k3을 시각화하기 위해, tfidf_matrix를 2D(2차원)로 그려야 함. 하지만 tfidf_matrix는 (예를 들어) 수천 개의 단어(feature)로 이루어진 수천 차원의 초고차원 공간임. \(\Rightarrow\) fviz_cluster는 이 수천 차원의 데이터를 2D로 ’압축 요약’하기 위해 내부적으로 PCA를 실행함.
    • Dim1(x축): 2만 단어 차원에 흩어진 전체 정보(분산)를 가장 많이(1순위로) 요약하는 새로운 ‘가상의 축’(PC1)임.
    • Dim2(y축): Dim1이 설명하지 못한 나머지 정보 중에서 가장 많이(2순위로) 요약하는, Dim1과 직교하는(90도로 만나는 = 서로 간에 상관이 존재하지 않는) ‘가상의 축’(PC2)임.
    • 따라서 이 플롯은 수천 차원에 존재하는 문서들의 위치를 2차원 평면에 사영(projection)시킨, 일종의 2D 요약본 또는 그림자임.
b. Dim1, Dim2 숫자(0.6%, 0.4%)의 의미
  • Dim1(0.6%): “x축(PC1)은 원본 TF-IDF 행렬이 가진 전체 정보(분산)의 단 0.6%만을 설명(요약)하고 있음.”
  • Dim2(0.4%): “y축(PC2)은 전체 정보의 단 0.4%만을 설명하고 있음.”
  • 핵심해석: 이 두 축을 합쳐도, 이 2D 플롯은 원본 데이터가 가진 정보의 총 1.0%(0.6% + 0.4%)밖에 보여주지 못함. \(\Rightarrow\) 이는 이 플롯에서 보이지 않는 99%의 정보가 Dim3, Dim4, …, Dim5000에 숨어 있다는 뜻!
  • 왜 이렇게 낮게 나오는가?: 이는 ’차원의 저주’가 극심하게 나타나는 DFM/TF-IDF 행렬의 전형적인 특징임. 수천 개의 단어 차원에 정보가 매우 얇고 넓게(희소하게) 퍼져 있기 때문에, 단 2개의 축(PC1, PC2)으로는 이 정보를 거의 요약할 수 없는 것.
c. x축과 y축 숫자의 의미
  • x축 숫자(-20, -10, 0, 10…)와 y축 숫자(0, 10, 20…)들은 이 2D 요약 차트의 좌표 또는 점수(score)를 의미.
  • 플롯을 해석하는 순서
    1. 축의 정체 파악
    • Dim1(x축)은 수천 개 단어의 정보를 요약한 ‘제1 수직선(PC1)’.
    • Dim2(y축)는 수천 개 단어의 정보를 요약한 ‘제2 수직선(PC2)’.
    1. 좌표 원점(0, 0)의 의미: x축의 0과 y축의 0이 만나는 (0, 0) 지점은 이 데이터셋에 있는 모든 문서들의 평균 지점(무게 중심)을 의미.
    2. 숫자(10, 20)의 의미(PC 점수)
    • x=0(평균)에서 멀어질수록, 해당 문서가 그 축의 ’특성’을 더 강하게 갖는다는 뜻.
    • x축(Dim1)의 -10, -20이라는 숫자는 원점(0)에서 ’Dim1 축의 음(-)의 방향’으로 얼마나 멀리 떨어져 있는지를 나타내는 좌표 값(PC 점수)임.
d. 이 점수를 텍스트와 연결하여 해석하는 방법
  • 가령, 우리가 fviz_pca_var(), fviz_contrib()를 통해 Dim1(x축)의 정체성을 다음과 같이 파악했다고 가정해보자.
    • Dim1의 양(+)의 방향(x축의 오른쪽): ‘배송’, ‘출발’, ‘기사’ 단어들이 강하게 기여함.
    • Dim1의 음(-)의 방향(x축의 왼쪽): ‘환불’, ‘입금’, ‘취소’ 단어들이 강하게 기여함.
  • 이 가정을 바탕으로 해당 플롯의 x축 숫자를 해석하면..
    • x축 점수 -20 근처에 있는 문서([EX] s1_20210913_0001_022)
      • 이 문서는 ’Dim1 점수’의 값이 -20점임.
      • 이는 이 문서의 TF-IDF 벡터를 PC1 레시피로 계산했더니, ‘배송’ 관련 단어는 거의 없고, ‘환불/입금’ 관련 단어는 압도적으로 많이 포함하고 있다는 강력한 증거.
  • x축 점수 5 근처에 있는 문서(플롯의 맨 왼쪽)
    • 이 문서들은 ’Dim1 점수’가 5점임.
    • 이는 이 문서들이 ‘환불’ 관련 단어는 적고, ‘배송/기사’ 관련 단어는 매우 많이 포함하고 있다는 뜻.
  • x축 점수 0 근처에 있는 문서(플롯의 중앙 세로선)
    • 이 문서들은 ‘Dim1’ 기준으로는 평균.
    • ‘환불’과 ’배송’ 관련 단어가 둘 다 적게 나왔거나, 혹은 둘 다 많이 나와서 서로의 점수를 상쇄시켰을 수 있음. 이 문서들은 Dim1이 아닌 Dim2(y축)의 점수로 그 특성을 파악해야 함.
  • 요약: x축의 0, -10, -20은 각 문서가 Dim1이라는 새로운 수직선 위에 어디쯤 위치하는지를 나타내는 주성분 점수(principal component score)임.
e. 전반적인 플롯 패턴(거대한 중첩) 해석
  • 패턴 관찰: 플롯을 보면, 1번(빨강), 2번(초록), 3번(파랑) 군집이 명확히 분리되지 않고, 거의 하나의 거대한 구름처럼 서로 뒤섞여(overlap) 있음.
  • 해석
    • 질문: “아까 실루엣 계수(fviz_nbclust)는 k=3이 최적이라고(수학적으로 잘 분리된다고) 했는데, 왜 이 플롯에서는 3개의 군집이 완전히 겹쳐 보이는가?”
    • 답변: “이 플롯은 데이터 정보의 단 1%만 보여주는 불완전한 그림자이기 때문이다.”
  • 비유
    • 방 안에 3개의 다른 모기떼(군집 1, 2, 3)가 (1) 바닥 근처, (2) 천장 근처, (3) 방 가운데에 명확히 나뉘어 날아다니고 있다고 상상해보자(이때 ’높이’가 우리가 못 보는 Dim3임).
    • 이 플롯은 그 모기떼들을 바닥(Dim1, Dim2)에 비춘 그림자임. 바닥에 비친 그림자만 보면, 3개의 모기떼는 높이(Dim3) 정보가 사라진 채 모두 겹쳐서 하나의 거대한 그림자로 보일 것.
  • 결론
    • fviz_cluster 플롯의 거대한 중첩은 pam() 모델이 군집화에 ’실패’했다는 뜻이 아님.
    • 이 플롯이 우리에게 주는 정보: “TF-IDF로 계산된 3개의 PAM 군집은 실제로 (수학적으로) 분리되어 있지만, 그 분리는 Dim1(0.6%)과 Dim2(0.4%)라는 얕은 차원에서는 일어나지 않고, 우리가 보지 못하는 99%의 고차원(Dim3, Dim4…)에서 일어나고 있음.”
    • 따라서 이 플롯만 보고 “군집이 겹쳐 있으니 망했다!”라고 해석해선 안 되며, “2D 시각화가 이 데이터의 복잡성을 표현하기에는 역부족이다..”라고 해석하는 것이 올바른 결론.

B. K=5일 경우

# PAM 모델 실행
# cluster 패키지의 pam() 함수로 K-Medoids 군집화 실행
pam_result_k5 <- pam(tfidf_matrix, # 입력 데이터(숫자 행렬)
                  k = 5 # 데이터셋의 실제 주제 개수 K 값(5개)
                  )

# 군집 할당 결과 확인
# 결과객체의 $clustering 슬롯에 각 문서가 할당된 군집번호가 저장됨.
pam_result_k5$clustering

# 군집 시각화
# fviz_cluster: factoextra의 군집 시각화 함수
# 내부적으로 PCA 또는 MDS를 실행하여 고차원 데이터를 2D로 축소 후 시각화.
uns_cluster_k5 <- fviz_cluster(
  pam_result_k5, # PAM 모델 결과 객체
  data = tfidf_matrix, # 원본 데이터(필수는 아님)
  ellipse.type = "convex", # 군집을 볼록 껍질(convex hull)로 표시
  ggtheme = theme_bw() # 그래프 테마
  )
ggsave("uns_cluster_k5_plot.png", uns_cluster_k5, width = 10, height = 10)
uns_cluster_k5

C. 하이브리드 모델 & K=5일 경우

# tfidf_matrix 행렬과 화행정보 speech_act_matrix 행렬을 열 단위로 합치기.
hybrid_uns_matrix <- cbind(tfidf_matrix, speech_act_matrix)

# 주의사항: 스케일링 필수!
# TF-IDF 값과 화행 빈도 값의 범위를 통일
hybrid_matrix_scaled <- scale(hybrid_uns_matrix)

# PAM 모델 실행
# cluster 패키지의 pam() 함수로 스케일링된 하이브리드 행렬을 사용하여 K-Medoids 군집화 실행
pam_result_hybrid <- pam(hybrid_matrix_scaled, k = 5)

# 군집 할당 결과 확인
# 결과객체의 $clustering 슬롯에 각 문서가 할당된 군집번호가 저장됨.
pam_result_hybrid$clustering

# 군집 시각화
# fviz_cluster: factoextra의 군집 시각화 함수
# 내부적으로 PCA 또는 MDS를 실행하여 고차원 데이터를 2D로 축소 후 시각화.
uns_hybrid_cluster_k5 <- fviz_cluster(
  pam_result_hybrid, # PAM 모델 결과 객체
  data = hybrid_matrix_scaled, # 원본 데이터(필수는 아님)
  ellipse.type = "convex", # 군집을 볼록 껍질(convex hull)로 표시
  ggtheme = theme_bw() # 그래프 테마
  )
ggsave("uns_hybrid_cluster_k5_plot.png", uns_hybrid_cluster_k5, width = 10, height = 10)
uns_hybrid_cluster_k5

3) 군집결과 검증하기: 정답과 비교

A. 모델이 찾은 군집과 실제 주제 간 호응 상태 확인: K=3일 경우

가. 실습
  • 모델이 찾은 군집(1, 2, 3)이 실제 주제(‘AS 문의’, ‘제품사용 문의’, ‘주문결제’, ‘배송’, ‘환불/반품/교환’)와 어떻게 호응하는지 확인해봄.
load("pam_result_k3.rda")

# '정답' 레이블 로드
# 앞서 docvars()로 Corpus에 저장해둔 'true_topic' 메타데이터를 가져옴.
true_topics <- docvars(uns_corpus, "true_topic")

# '모델 예측(k=3)' 레이블 로드
# pam() 모델링 결과(k=3)에서 각 문서의 군집할당($clustering) 정보를 가져옴.
model_clusters_k3 <- pam_result_k3$clustering

# 혼동행렬(confusion matrix)로 비교
# R 기본함수 table()을 사용하여 '실제 정답'과 '모델의 예측'을 비교.
validation_table_k3 <- table(True = true_topics, Model = model_clusters_k3)
validation_table_k3
##                 Model
## True               1   2   3
##   AS문의         314 617  69
##   배송           233 391 376
##   제품/사용문의  252 688  60
##   주문/결제      251 316 433
##   환불/반품/교환 546 344 110
나. 결과해석(K=3)
  • 핵심요약: TF-IDF 행렬이 3개의 매우 강력한 ’초(super)군집’을 가지고 있음을 보여줌. “실루엣 계수가 k=3을 제안한 이유는, 모델이 5개의 주제 중 어휘적(TF-IDF)으로 매우 유사한 주제들을 하나의 의미 그룹으로 합쳤기 때문임. \(\Rightarrow\) 이는 모델의 ’실패’가 아니라, 인간이 5개로 나눈 주제들이 실제로는 3개의 큰 어휘적 덩어리로 구성되어 있음을 발견한 것임.
a. 개별 군집(Model)의 정체성 파악
  • Model 1: “환불/금전 군집”
    • 이 군집은 환불/반품/교환(546건) 주제를 압도적으로 포함하고 있음.
    • 다른 모든 주제(AS, 배송, 제품, 주문)도 200~300건씩 이 군집에 포함되는데, 이는 이 주제들이 ‘환불’과 관련된 어휘([EX] ’금액’, ‘결제’, ‘취소’)를 공유하는 하위 그룹을 가지고 있음을 의미.
    • 군집 1의 정체성: 환불/반품/교환을 핵심으로 하는 환불 및 금전 처리 관련 어휘 군집.
  • Model 2: “제품/AS 지원 군집”
    • 이 군집은 제품/사용문의(688건)와 AS문의(617건)가 강력하게 결합된 가장 큰 덩어리.
    • ‘작동’, ‘방법’, ‘서비스’, ‘고장’ 등의 단어가 AS문의제품문의 모두에서 사용되어, 모델이 이 둘을 하나의 주제(“제품지원”)로 인식하고 있음을 명확히 보여줌.
    • 군집 2의 정체성: 제품문의AS문의를 구분하지 못하는 제품 및 AS 지원 어휘군집.
  • Model 3: “거래/물류 군집”
    • 이 군집은 주문/결제(433건)와 배송(376건)이 짝을 이루고 있음.
    • 이는 ‘주문’, ‘결제’, ‘출고’, ‘송장’ 등 “거래 및 물류”와 관련된 어휘를 공유하는 주제들을 성공적으로 묶어낸 것.
  • 요약: 해당 TF-IDF 데이터의 자연스러운 어휘 구조는 (1) 환불, (2) 제품/AS, (3) 주문/배송이라는 3개의 뚜렷한 덩어리임.
b. 실제 주제(True)의 분포 파악
  • 이 분석의 핵심은 “인간이 나눈 5개의 주제가 모델이 볼 때(어휘적으로) 얼마나 순수하게 독립적인가?”를 확인하는 것!
  1. “닻(anchor)”이 되는 주제들: 군집의 정체성을 정의함
  • 환불/반품/교환(Row 5)
    • 546건(1번), 344건(2번), 110건(3번).
    • 가장 높은 값(546건)을 Model 1에 할당하며, 이 군집의 정체성을 “환불/금전”으로 정의하는 가장 강력한 ‘닻(anchor)’ 주제임.
    • 흥미로운 점: 344건이 Model 2(“제품/AS”)에 할당됨. \(\Rightarrow\) 이는 “제품이 고장나서(Model 2 어휘) 환불(Model 1 어휘)하고 싶다”처럼, 제품 문제와 환불이 결합된 하이브리드 문서가 344건이나 존재함을 의미.
  • 제품/사용문의(Row 3)
    • 252건(1번), 688건(2번), 60건(3번).
    • 가장 높은 값(688건)을 Model 2에 할당하며, AS문의와 함께 “제품/AS” 군집을 정의하는 두 번째 ’닻’임.
    • 흥미로운 점: 252건이 Model 1(“환불”)에 할당됨. \(\Rightarrow\) 이는 “제품(Model 2)을 구매했는데, 불만족 시 환불(Model 1) 규정이 궁금하다”처럼, 제품과 환불 규정이 결합된 문서들일 것.
  • 주문/결제(Row 4)
    • 251건(1번), 316건(2번), 433건(3번).
    • 가장 높은 값(433건)을 Model 3에 할당하며, 배송과 함께 “거래/물류” 군집을 정의하는 세 번째 ’닻’임.
    • 흥미로운 점: 316건이 Model 2(“제품/AS”)에, 251건이 Model 1(“환불”)에 할당됨. \(\Rightarrow\) 이는 “제품(Model 2)을 주문(Model 3)한다” 또는 “주문(Model 3)을 취소/환불(Model 1)한다”처럼, ’주문’이라는 행위가 다른 모든 주제와 밀접하게 연결되어 있음을 보여줌.
  1. “하이브리드” 및 “허브” 주제들: 어휘적 경계의 모호성
  • AS문의(Row 1): 하이브리드 주제
    • 314건(1번), 617건(2번), 69건(3번).
    • 이 주제는 독립적인 군집을 형성하지 못하고, 가장 높은 값(617건)을 Model 2(“제품/AS”)에 할당함.
    • 결정적 해석: 이는 TF-IDF(어휘) 관점에서 볼 때, AS문의제품/사용문의와 어휘적으로 거의 구분이 불가능하며, 사실상 ‘제품/AS’ 군집의 강력한 하위주제임을 의미.
    • 또한 314건이라는 많은 수가 Model 1(“환불”)에 할당된 것은, AS문의 중 상당수(약 30%)가 “수리비 환불”, “고장으로 인한 환불” 등 금전 문제와 결합된 하이브리드 속성을 가지고 있음을 나타냄.
  • 배송(Row 2): 허브 주제
    • 233건(1번), 391건(2번), 376건(3번).
    • 5개 주제 중 가장 어휘적 정체성이 모호한 주제. 특정 군집에 압도적으로 쏠리지 않고, 3개 군집 모두에 유의미하게 분포.
    • 결정적 해석: ’배송’은 단일 주제가 아니라, 다른 모든 주제와 연결되는 “허브(hub)” 역할을 할 것.
      • Model 3(376건): 주문/결제와 묶여 “순수 물류”([EX] “배송 출발”).
      • Model 2(391건): 제품/AS와 묶임 ([EX] “제품/AS부품 배송 문의”).
      • Model 1(233건): 환불과 묶임([EX] “배송지연으로 인한 환불”).
c. 결론
  • 비지도학습은 ’정답’을 맞추는 것이 아니라, 데이터의 숨겨진 구조를 찾는 것. \(\Rightarrow\) 본 결과는 인간이 정의한 5개 주제가 어휘적으로는 5개의 독립된 섬이 아님을 보여줌.
  • 실루엣 계수(k=3)가 정답(k=5)과 달랐던 이유: 환불(1번), 제품(2번), 주문(3번)이라는 3개의 큰 어휘적 ‘대륙’이 존재하며, AS문의는 ’제품’(2번) 대륙에 속한 큰 ‘주(state)’이고, 배송은 이 모든 대륙을 연결하는 ’허브(hub)’ 공항과 같은 역할을 하고 있음.
  • 다음 단계 제안: 만약 5개 주제를 더 잘 분리하고 싶다면, k=5로 강제 실행해보거나 TF-IDF 외에 다른 피처(feature)를 추가하는 것을 고려해야 함.
d. 심화
  • TF-IDF만 사용했을 때 AS문의제품/사용문의가 합쳐진 것은, 두 주제가 사용하는 단어의 풀(pool)이 매우 유사하기 때문. 모델은 ‘고장’, ‘작동’, ‘기사’, ‘확인’, ‘서비스’ 같은 단어들만 보고는 두 주제를 구분할 ’결정적인 증거’를 찾지 못한 것.
  • 5개 주제를 더 잘 분리하고 싶다면, “어떤 단어를 썼는가”(TF-IDF) 외에 대화가 어떻게 흘러갔는가 또는 단어의 문맥이 어떠했는가를 알려주는 피처를 추가해야 함.
  • ‘화행(speech act)’ 피처 추가
    • 데이터셋의 구조를 보면, speechAct(화행) 정보가 이미 라벨링되어 있음([EX] ‘진술하기’, ‘질문하기’, ‘부탁하기’).
    • 이유
      • TF-IDF가 구분에 실패한 AS문의제품/사용문의대화의 목적과 흐름(pragmatic flow)이 다를 것.
      • AS문의는 ‘불만’이나 ’요청’(‘부탁하기’, ‘명령하기/요구하기’)의 비율이 높을 것.
      • 제품/사용문의는 순수한 ’질문하기’의 비율이 높을 것.
      • 주문/결제는 ‘진술하기’(확인)의 비율이 높을 것.
    • 적용방법
      1. 기존 tfidf_matrix와는 별개로, 각 문서(대화)별로 화자(A/B)와 화행을 조합한 빈도 행렬 만들기([EX] ‘A_질문하기’, ‘B_질문하기_B’, ‘A_부탁하기_A’, ‘B_부탁하기’… 등을 열로 가짐).
      2. speech_act_matrix(문서 x 화행)와 기존 tfidf_matrix(문서 x 단어)를 cbind()로 합쳐서 하이브리드 피처 행렬을 만듦.
      3. 주의사항: tfidf_matrixspeech_act_matrix는 값의 범위(scale)가 다르므로, cbind()로 합친 최종 행렬을 scale() 함수로 표준화한 뒤 pam()PCA()에 넣을 것!
e. 표준화(standardization/scaling) 단계를 추가한다면?
  • 흥미로운 결과이지만, 매우 주의해서 해석해야 함. 왜냐하면 편향된(biased) 결과이기 때문. 이 분석에서 가장 중요하고도 치명적인 단계가 생략됨.
  • 표준화의 중요성: pam()은 ‘거리’ 기반 알고리즘. TF-IDF 행렬에는 ‘환불’처럼 비교적 드물게 등장하지만 일단 등장하면 TF-IDF 값이 매우 높게 튀는 ’킬로미터(km)’ 단위의 피처([EX] 값의 범위가 0.0~5.0)와 ‘배송’처럼 더 자주 등장하지만 IDF가 낮아져 TF-IDF 값이 상대적으로 낮고 고르게 분포하는 ’센티미터(cm)’ 단위의 피처([EX] 값의 범위가 0.0~0.5)가 섞여 있음.
  • 결론: 우리가 앞서 본 ‘그럴듯한’ 3개 군집은, 데이터의 진짜 구조가 아니라 ‘환불’처럼 값이 튀는 소수의 ’시끄러운’ 단어들이 모든 거리를 좌우한 ‘편향된’ 결과였던 것.
load("pam_result_k3_scaled.rda")

# '정답' 레이블 로드
# 앞서 docvars()로 Corpus에 저장해둔 'true_topic' 메타데이터를 가져옴.
true_topics <- docvars(uns_corpus, "true_topic")

# '모델 예측(k=3)' 레이블 로드
# pam() 모델링 결과(k=3)에서 각 문서의 군집할당($clustering) 정보를 가져옴.
model_clusters_k3_scaled <- pam_result_k3_scaled$clustering

# 혼동행렬(confusion matrix)로 비교
# R 기본함수 table()을 사용하여 '실제 정답'과 '모델의 예측'을 비교.
validation_table_k3_scaled <- table(True = true_topics, Model = model_clusters_k3_scaled)
validation_table_k3_scaled
##                 Model
## True               1   2   3
##   AS문의         812 125  63
##   배송           665 335   0
##   제품/사용문의  635 365   0
##   주문/결제      634 363   3
##   환불/반품/교환 778 222   0
  • 해석
    • 상기 결과가 이 데이터의 ‘진짜’ 구조일 것. \(\Rightarrow\) 3개의 덩어리가 아니라, 한 개의 거대한 덩어리(Model 1)와 한 개의 파편화된 군집(Model 2) 그리고 한 개의 극단적인 이상치(Model 3)로 구성.
    • 수학적 증거: 표준화된 TF-IDF 행렬로 ’최적의 K’를 찾은 실루엣 플롯을 보라. 겉보기엔 k=3이 최적이라고 나옴. 하지만 Y축의 값을 보면 +0.008임. 0과 다름없는 이 점수는 사실상 ’구조 없음’을 의미. 이 플롯이 k=3을 ’최적’이라고 한 이유는 ’거대 덩어리(k=1)’에서 ’이상치 2개(k=3)’를 떼어내는 것이 0.008만큼 더 낫다고 수학적으로 알려준 것일 뿐.
    • 결론: 결국 pam() 테이블과 실루엣 플롯 둘 다 이 데이터는 한 개의 덩어리와 소수의 이상치로 구성되어 있다는 동일한 진실을 말하고 있었음.

B. 모델이 찾은 군집과 실제 주제 간 호응 상태 확인: K=5일 경우

가. 실습
  • 모델이 찾은 군집(1, 2, 3, 4, 5)이 실제 주제(‘AS 문의’, ‘제품사용 문의’, ‘주문결제’, ‘배송’, ‘환불/반품/교환’)와 어떻게 호응하는지 확인해봄.
load("pam_result_k5.rda")

# '정답' 레이블 로드
# 앞서 docvars()로 Corpus에 저장해둔 'true_topic' 메타데이터를 가져옴.
true_topics <- docvars(uns_corpus, "true_topic")

# '모델 예측(k=5)' 레이블 로드
# pam() 모델링 결과(k=5)에서 각 문서의 군집할당($clustering) 정보를 가져옴.
model_clusters_k5 <- pam_result_k5$clustering

# 혼동행렬(confusion matrix)로 비교-
# R 기본함수 table()을 사용하여 '실제 정답'과 '모델의 예측'을 비교.
validation_table_k5 <- table(True = true_topics, Model = model_clusters_k5)
validation_table_k5
##                 Model
## True               1   2   3   4   5
##   AS문의         258 514 179  37  12
##   배송           184 342  46 295 133
##   제품/사용문의  243 665   5  54  33
##   주문/결제      190 243  87 354 126
##   환불/반품/교환 479 307  17  67 130
나. 해석
  • 핵심요약: 데이터의 ’어휘적 구조’가 ’인간이 정의한 5개 주제’보다 더 강력하게 작동했음! \(\Rightarrow\) pam() 알고리즘에게 “군집 5개를 만들라”고 강제로 명령했지만, TF-IDF로 계산된 데이터의 실제 구조는 3개의 거대한 덩어리로 뭉치려는 힘이 너무 강했음. 특히 k=3에서 보았던 강력한 어휘적 결합(특히 제품/AS)을 깨뜨리지 못했음. 알고리즘은 이 두 명령 사이에서 “타협”을 한 것.
a. 세 개의 핵심군집(Model 1, 2, 4 번)
  • Model 1: 여전히 환불/반품/교환(479건)을 중심으로 한 “환불” 군집.
  • Model 2: 여전히 제품/사용문의(665건)와 AS문의(514건)가 묶인 “제품/AS” 군집.
  • Model 4: 주문/결제(354건)와 배송(295건)이 묶인 “거래/물류” 군집.
  • k=3에서 봤던 3개의 핵심구조가 Model 1, 2, 4로 이름만 바뀐 채 그대로 나타남.
b. 두 개의 파편군집(Model 3, 5번)
  • 모델은 5개의 군집을 만들라는 명령을 수행하기 위해, 기존의 강력한 군집들(1, 2, 4)에서 떨어져 나온 ’파편(splinter)’들로 나머지 군집을 채움.
  • Model 3은 AS문의(179건)의 일부를, Model 5는 배송(133건)과 주문(126건)의 일부를 가져가며 의미 있는 주제군집을 형성하는 데 실패.
c. 최종결론
  • k=5 결과는 실루엣 계수가 k=3이라고 한 것이 옳았음을 증명함.
  • TF-IDF라는 ‘단어 빈도’ 피처만으로는 이 5개 주제를 분리해낼 수 없다는 것이 명확해짐. 모델은 k=5라는 명령에도 불구하고, 어휘적으로 가장 강력한 3개의 덩어리를 그대로 유지함.
  • 5개 주제를 제대로 분리하려면, TF-IDF 외에 화행(speech act)처럼 대화의 ’목적’이나 ’흐름’을 알려주는 새로운 피처(feature)를 추가하는 작업이 반드시 필요.

C. 하이브리드 모델이 찾은 군집과 실제 주제 간 호응 상태 확인: K=5일 경우

가. 실습
  • 하이브리드 모델(화행 피처 추가)이 찾은 군집(1, 2, 3, 4, 5)이 실제 주제(‘AS 문의’, ‘제품사용 문의’, ‘주문결제’, ‘배송’, ‘환불/반품/교환’)와 어떻게 호응하는지 확인해봄.
load("pam_result_hybrid.rda")
load("true_topics_hybrid.rda")

# '정답' 레이블 로드
true_topics_hybrid

# '모델 예측(k=5)' 레이블 로드
# pam() 하이브리드 모델링 결과(k=5)에서 각 문서의 군집할당($clustering) 정보를 가져옴.
model_hybrid_clusters_k5 <- pam_result_hybrid$clustering

# 혼동행렬(confusion matrix)로 비교
# R 기본함수 table()을 사용하여 '실제 정답'과 '모델의 예측'을 비교.
validation_hybrid_table_k5 <- table(True = true_topics_hybrid, Model = model_hybrid_clusters_k5)

print("Validation validation_hybrid_table_k5(True vs. Model):")
validation_hybrid_table_k5

# [결과]
#                  Model
# True              1   2   3   4   5
#  AS문의         209 345 314  63   2
#  배송           225 517 197   0   0
#  제품/사용문의   52 810  88   0   0
#  주문/결제      130 648 190   2   0
#  환불/반품/교환 194 530 214   0   0
나. 해석
  • 핵심요약
    • 1, 2순위 노이즈(A_진술하기, A_인사하기)를 성공적으로 제거했지만, 모델은 곧바로 2순위로 가장 흔한 공통점을 찾아내 그것을 기준으로 새로운 쓰레기통 군집(Model 2)을 만듦. \(\Rightarrow\) 이는 현재 우리가 가진 ‘어휘’와 ’화행’ 피처만으로는 5개 주제를 분리할 ’결정적인 신호(signal)’가 부족하다는 강력한 증거임.
    • 군집화의 기준이 ’어휘(TF-IDF)’에서 ’대화 패턴(speech acts)’으로 이동했으며, 이로 인해 AS문의가 성공적으로 분리되었으나, 나머지 4개 주제가 ’단순대화’라는 새로운 공통점으로 묶이게 됨.
a. Model 2: 새로운 거대군집
  • Model 2가 모든 주제의 문서를 500~800건씩 빨아들임. \(\Rightarrow\) 제품/AS 군집과 주문/배송 군집이 합쳐진, 환불을 제외한 거의 모든 것을 담는 거대군집이 됨.
  • Model 2: “표준/단순 대화 군집”
    • 이 군집은 제품/사용문의(810건), 주문/결제(648건), 환불/반품/교환(530건), 배송(517건) 등 AS문의를 제외한 모든 주제의 과반수를 흡수.
    • 해석: 이는 ‘화행’ 피처가 AS문의를 제외한 4개 주제가 유사한 ’대화 패턴’을 공유한다는 것을 발견했음을 의미.
    • 이 4개 주제는 ‘화행적 불용어’(A_진술하기 등)를 제거했음에도 불구하고, “단순질문 \(\rightarrow\) 단순답변” 또는 “정보 확인 \(\rightarrow\) 처리”와 같이 남아 있는 A_질문하기 등과 패턴이 매우 유사할 수 있음. Model 2는 이 “표준적인 고객응대 패턴”을 가진 문서들을 모두 묶은 것.
b. Model 1과 3: 새로운 분리군집
  • Model 2가 대부분을 가져간 상태에서, Model 1과 3이 남은 문서들을 나눠가짐.
    • Model 1: AS문의(209), 배송(225), 환불(194).
    • Model 3: AS문의(314), 배송(197), 환불(214).
  • 두 군집 모두 AS문의환불/배송 주제가 섞여 있음. 이는 이 3개 주제가 ’문제해결’이라는 속성을 공유하며 여전히 명확하게 분리되지 못하고, 억지로 k=5를 맞추기 위해 파편화되었음을 보여줌.
  • Model 1, 3: “AS 및 복잡한 문제 군집”
    • AS문의는 유일하게 Model 2(345건)에 쏠리지 않고, Model 1(209건)과 Model 3(314건)으로 성공적으로 분리.
    • 배송환불 주제의 일부(각각 200건 내외)도 이 군집들로 따라옴.
    • 해석: 화행 피처가 추가되자, 모델은 AS문의제품/사용문의와는 대화 패턴이 완전히 다르다는 것을 감지. \(\Rightarrow\) AS문의는 ‘단순질문’이 아닌 ’문제제기’, ‘불만’, ‘요청’ 등 “복잡하고 비표준적인 대화 패턴”을 가지며, 모델은 이 패턴을 Model 1과 3으로 분리해낸 것.
c. Model 4와 5의 기능부전
  • Model 4와 5는 AS문의 63건, 2건 등을 가져가는 등, 통계적으로 거의 비어있는 “먼지” 군집이 되었음. \(\Rightarrow\) PAM 알고리즘이 5개의 의미 있는 중심점(medoid)을 찾는 데 완전히 실패했음을 보여줌.
  • 하이브리드 피처로도 5개의 뚜렷한 군집을 찾기에는 역부족이었음을 보여줌.
d. 왜 이런 결과가 나왔는가?
  • 가설 1(가장 유력): 피처의 한계
    • TF-IDF(어휘): k=3 TF-IDF Only 결과에서 봤듯이, 제품/AS주문/배송은 어휘적으로 매우 유사.
    • 필터링된 화행: A_진술하기, A_인사하기 등을 뺐더니, 남은 ‘신호’ 화행들([EX] B_부탁하기, B_부정감정표현하기)마저 여러 주제에 걸쳐 공통적으로 나타났을 가능성이 큼([EX] AS문의환불문의 둘 다 부정감정표현하기를 사용함).
  • 결국 노이즈를 걷어낸 후 남은 어휘 피처와 화행 피처 둘 다, 5개 주제를 구분할 결정적 ’신호’가 아니라, 여전히 ’공통점’만을 가리키고 있었던 것. \(\Rightarrow\) Model 2는 이 “일반적인 문의/거래”라는 거대한 공통점을 잡아낸 것.
  • 가설 2(교란변수): 270개 문서 삭제
    • ’흔한 화행’을 제거하는 통에 270개(5000개 \(\rightarrow\) 4730개)의 문서가 삭제됨. 만약 이 문서들이 ’단순 주문/결제’나 ’단순 제품문의’처럼 주제를 명확히 구분해줄 수 있는 쉬운 예제들이었다면, 우리는 핵심 데이터를 잃어버린 셈이 됨. \(\Rightarrow\) ’어려운 예제’만 남은 데이터셋이 되어 군집화가 더 엉망이 되었을 수 있음.
  • 결론
    • 하이브리드 모델은 TF-IDF가 실패했던 제품/AS 분리라는 가장 어려운 과제를 성공적으로 해냄(제품은 Model 2로, AS는 Model 1/3으로). 하지만 그 대가로 ’화행 패턴’이 유사했던 나머지 4개 주제가 Model 2라는 하나의 거대 군집으로 합쳐지는 ’쏠림 현상’이 발생. 이는 모델이 이제 ’어휘’가 아닌 ’대화의 복잡성/패턴’을 기준으로 데이터를 재편성했음을 보여주는 매우 흥미로운 결과임.
    • 피처 엔지니어링(노이즈 제거)은 필수적이지만, 만병통치약은 아님. \(\Rightarrow\) 노이즈를 제거했음에도 군집화가 실패했다면, 이는 우리가 가진 피처(TF-IDF, 화행 빈도)의 표현력(representational power) 자체가 5개 주제를 분리하기에 근본적으로 부족하다는 것을 의미.
    • 대안: N-gram을 추가하거나, BERT 임베딩 같은 더 복잡한 피처를 사용해야만 풀 수 있는 문제일 수 있음.

3. 3단계: 차원축소(PCA)

  • 목표: 수천 개의 ‘단어’ 차원을 2~3개의 ’핵심 의미축(semantic axis)’으로 요약하고, 문서들을 ’의미공간’에 배치함.

(1) 이론

1) 차원축소(dimensionality reduction)

  • 문제상황: ‘차원의 저주’(curse of dimensionality)
    • 우리의 TF-IDF 행렬은 1만 개의 문서(행[rows])를 설명하기 위해 수천 개의 단어(열[cols], 즉 ‘차원’)를 사용함.
    • 차원이 데이터 개수(문서 수)보다 훨씬 많으면, 모든 문서가 서로 멀리 드문드문 떨어져 있는 것처럼 보임(sparsity). 이는 군집화나 시각화를 매우 어렵게 만듦.
  • 해결책
    • ’정보손실’을 최소화하면서, 수천 개의 단어 차원을 2~3개의 ’핵심 대표차원’으로 압축(요약)함.
    • 비록 데이터의 손실이 있을지라도 수천 차원의 데이터가 2~3차원으로도 뚜렷하게 보이게 됨. 수천 차원의 데이터를 2~3차원으로 줄였으니, 차원의 축소가 일어난 것.
    • 이때 데이터를 어떤 방향에서 봐야 가장 적절할까, 즉 가장 데이터의 변동이 잘 보일까를 찾는 것이 PCA임. \(\Rightarrow\) 처음에는 데이터 포인트들의 구조를 파악하기 힘들지만 적절하게 회전시키면 숨겨진 구조가 나옴. 이처럼 PCA는 데이터 주위를 돌면서 가장 적절한 관찰방향을 찾는 것!
    • 예시: 100%의 설명력을 위해서는 100차원을 써야 함. 그러나 90%의 설명력을 위해 10차원만 써도 된다면 자료의 손실 10%를 감내하더라도, 후자를 사용하는 것이 잡음제거와 데이터 활용 효율성을 위해 나은 선택이 됨! \(\Rightarrow\) 따라서 대부분의 분산이 처음 몇 개의 차원에 표현되도록 데이터를 회전시킴!

[그림 4] 데이터 주위를 돌면서 가장 적절한 관찰방향 찾기

2) 차원축소 기법

A. 주성분 분석(principal component analysis, PCA)

  • 주성분 분석의 기본개념
    • PCA의 본질: ‘정보의 압축’. 정보(information) = 데이터가 흩어진 정도(분산, variance).
    • PCA는 수천 개 단어 차원 전체의 분산을 가장 잘 설명하는(즉 정보를 가장 많이 보존하는) ’새로운 축’을 찾아냄.
  • PC1(제1 주성분)과 ‘가중치 조합’
    • PCA가 찾는 ‘새로운 축’(PC1)은, 수천 개의 단어(원본 축) 중 하나를 선택하는 것이 아님.
    • 이 ’새로운 축’은 수천 개의 원본 축(단어)을 모두 사용해서 만든 ’새로운 가상의 축’임.
    • ’가중치 조합(weighted combination)’이란?
      • 이 ‘새로운 축’을 만드는 ’레시피’ 또는 ’공식’을 의미함.
      • 비유: 대학원생의 ‘연구역량’(PC1)이라는 새로운 지표를 만든다고 가정해보자.
        • 이 지표는 ‘논문작성’(원본 축 1), ‘코딩 실력’(원본 축 2), ‘발표능력’(원본 축 3) 등을 ’가중치 조합’하여 만듦.
        • 연구역량(PC1) =(0.6 * ‘논문작성’) + (0.3 * ‘코딩 실력’) + (0.1 * ‘발표능력’).
        • 여기서 0.6, 0.3, 0.1이 바로 가중치(weight) 또는 로딩(loading)임.
    • NLP 적용
      • PC1은 수천 개 단어의 ’가중치 조합’으로 만들어진 ’제1의 의미축’임.
      • \(PC1 =(c_1 \cdot \text{"AS"}) +(c_2 \cdot \text{"기사"}) +(c_3 \cdot \text{"환불"}) + ... +(c_{5000} \cdot \text{"시장"})\).
      • PCA 알고리즘은 원본 데이터의 분산을 가장 잘 설명할 수 있는 이 ‘가중치’ \(c_1, c_2, ...\)의 조합을 수학적으로(고윳값 분해를 통해) 찾아냄.

[그림 5] 차원축소 예시(2차원에서 1차원으로): 데이터 겹침의 문제 발생

[그림 6] 차원축소 예시(2차원에서 1차원으로): 분산을 잘 보존하는 방식으로 차원 축소하기

[그림 7] 차원축소와 주성분(PC)

B. 고윳값 분해(eigenvalue decomposition)

  • 이 수학적 과정을 여러분이 직접 처리할 필요는 없음. FactoMineR 패키지의 PCA() 함수가 이 모든 것을 자동으로 처리함. 하지만 ’어떤 원리’로 축을 찾는지 이해하면 PCA를 더 깊이 있게 활용할 수 있음.
  • 직관적 비유(데이터의 ‘뼈대’ 찾기)
    • 수만 개의 단어 차원으로 이루어진 우리의 텍스트 데이터를 ’거대한 데이터 구름(cloud)’이라고 상상해보자.
    • 이 구름은 찌그러진 타원체 모양일 것임([EX] ‘배송’ 관련 단어들 방향으로 길고, ‘환불’ 관련 단어들 방향으로 다음으로 긴 모양).
    • 고윳값 분해는 이 찌그러진 구름의 ‘뼈대(skeleton)’ 또는 ’주축(principal axes)’을 찾는 수학적 기법임.
  • 고유 벡터(eigenvector): 이 구름의 ‘뼈대’를 이루는 ’축’의 방향을 의미함. 이 방향이 바로 PCA의 ’가중치 조합’(레시피)임.
    • 가장 긴 뼈대(축)의 방향 \(\Rightarrow\) PC1(제1 주성분)
    • 두 번째로 긴 뼈대(축)의 방향(첫 번째 축과 90도) \(\rightarrow\) PC2(제2 주성분)
  • 고윳값(eigenvalue): 이 ’뼈대’의 길이(즉 중요도)를 의미
    • 가장 긴 뼈대(PC1)의 길이 \(\Rightarrow\) PC1이 설명하는 분산(정보)의 크기.
    • 두 번째 뼈대(PC2)의 길이 \(\Rightarrow\) PC2가 설명하는 분산(정보)의 크기.
    • PCA가 ’분산을 가장 잘 설명하는 축(PC)을 찾는다’는 말은, 수학적으로 ’데이터 공분산 행렬의 고윳값 분해를 통해 가장 큰 고윳값(분산)을 갖는 고유 벡터(축)를 찾는다’는 말과 같음.
  • PC2(제2 주성분):
    • 두 번째 축(PC2)은, PC1이 설명하고 남은 ‘잔여 분산’ 중에서 가장 많은 분산을 설명하는 축임.
    • ‘직교(orthogonal)’의 의미: PCA는 PC1과 PC2가 서로 ’직교’(즉 90도)가 되도록 설계됨. 이는 두 축이 통계적으로 완전히 독립적임을 의미함.([EX] PC1이 ‘연구역량’ 축이라면, PC2는 ‘연구역량’과는 전혀 상관없는 ’인간관계’ 축이 됨).
    • 이렇게 축들이 서로 독립적이기 때문에, 우리는 PC1과 PC2를 X, Y축으로 하는 2D 공간에 데이터를 뿌려 ’의미지도’를 만들 수 있음.

3) PCA 핵심 해석 지표

A. scree 플롯

  • 구현 함수: fviz_eig() \(\Rightarrow\) 몇 개의 축을 사용할 것인가?
  • 개념: 각 주성분(PC)이 원본 데이터의 분산(정보)을 몇 %나 설명하는지 보여주는 막대 그래프.
  • NLP에서의 해석: 텍스트 데이터는 초고차원(very high dimensionality)임. 수천 차원을 1차원으로 줄이는데 PC1이 60%를 설명해준다? \(\Rightarrow\) 불가능!
  • 기준: PC1이 10%, PC2가 8%만 설명해도, 이는 2만 단어 중 18%의 ‘핵심의미’를 단 두 개의 축으로 압축했다는 뜻이며 매우 유의미한 결과임. \(\Rightarrow\) ’누적 80%’ 룰에 집착하지 말고, 그래프가 급격히 꺾이는 elbow 지점이나, 해석 가능한 상위 2~3개의 축을 사용하자!

[그림 8] scree 플롯 예시

B. 기여도(Contribution)

가. +-
  • 역할
    • PC1 점수를 계산하는 실제 공식(레시피)은 +/- 부호가 있는 ‘가중치(weight)’(정확히는 ‘로딩’ 또는 ‘고유 벡터’)를 사용.
    • 예시: \(PC1 = (0.5 \cdot \text{'환불'}) + (0.4 \cdot \text{'입금'}) - (0.5 \cdot \text{'배송'}) - (0.4 \cdot \text{'출발'})\).
    • 어떤 문서가 ‘환불’, ‘입금’ 단어를 많이 포함하면, \((0.5 \times \text{'환불'}) + (0.4 \times \text{'입금'})\)… 이 되어 최종 \(Score_{PC1}\)높은 양수(+)가 됨.
    • 반대로 어떤 문서가 ‘배송’, ‘출발’ 단어를 많이 포함하면, \(... (-0.5 \times \text{'배송'}) + (-0.4 \times \text{'출발'})\)… 이 되어 최종 \(Score_{PC1}\)높은 음수(-)가 됨.
  • 의미
    • PC 축의 정체성은 무엇이며, 문서 점수는 어떻게 계산되는지를 결정.
    • 양의 부호(+)를 가진 단어들과 음의 부호(-)를 가진 단어들은 서로 ’반대되는 의미집합’을 형성함.
    • 어떤 문서의 PC1 점수가 높다면(+), 그 문서는 ‘+’ 단어들을 많이 포함하고 ‘-’ 단어들을 적게 포함한다는 뜻임.
    • 반대로 어떤 문서의 PC1 점수가 낮다면(-), 그 문서는 ‘-’ 단어들을 많이 포함하고 ‘+’ 단어들을 적게 포함한다는 뜻임.
  • 예시: \(PC1 = (0.5 \cdot \text{'환불'}) + (0.4 \cdot \text{'입금'}) - (0.5 \cdot \text{'배송'}) - (0.4 \cdot \text{'출발'})\).
  • 해석
    • 이 축은 ‘환불’, ‘입금’ 같은 단어들(환불/교환 주제)과 ‘배송’, ‘출발’ 같은 단어들(배송 주제)을 서로 반대방향으로 밀어내는 축.
      • + 방향(양의 극성): ‘환불’, ‘입금’ 단어들이 이 축의 양(+)의 방향을 정의.
      • - 방향(음의 극성): ‘배송’, ‘출발’ 단어들이 이 축의 음(-)의 방향을 정의.
    • 축의 이름을 붙일 때는 계수의 절댓값이 가장 큰 단어들(여기서는 ‘환불’과 ’배송’)에 주목함. ’입금’과 ’출발’도 기여하지만, ’환불’과 ’배송’의 가중치 절댓값(\(|-0.5|\), \(|0.5|\))이 가장 커서 축의 양극단을 가장 강력하게 정의하기 때문!
    • 결론: PC1 축은 ‘환불문의’(+방향)와 ‘배송문의’(-방향)를 구분하는 ‘환불/배송 스펙트럼’ 축으로 명명할 수 있음.
나. 기여도
  • 구현 함수
    • fviz_contrib(): PC 축에 ‘이름 붙이기’.
    • 기능: fviz_contrib()는 이 계수(로딩)의 ‘제곱값’(부호를 무시하고 순수 기여량만 봄)을 시각화하여, 어떤 단어가 이 축을 ’형성’하는 데 크게 기여했는지 한눈에 보여줌.
  • 개념: “이 PC 축의 ‘정체성’은 무엇인가?“를 파악하여, 축에 ’이름을 붙이는(naming)’ 가장 중요한 단계임.
  • 원리: 위에서 설명한 PC 축의 ‘레시피’(가중치 조합)를 시각화하는 것임.
    • \(PC1 =(c_1 \cdot \text{'단어1'}) + (c_2 \cdot \text{'단어2'}) + ...\)
    • 여기서 ‘가중치’ \(c_i\)(로딩)가 큰 단어일수록 그 축의 의미를 정의하는 데 ’크게 기여(contribute)’한 것임.
  • 의미: “그럼 이 ‘환불/배송 스펙트럼’ 축을 만드는 데 무엇이 가장 중요한 단어지?”
    • 이 질문에 답할 때, ‘환불’(가중치 0.5)과 ‘배송’(가중치 -0.5)은 방향만 반대일 뿐, 이 축을 정의하는 데 기여한 ’힘’이나 ’중요도’는 동일(둘 다 절댓값이 0.5로 가장 큼).
    • 만약 +/- 부호를 그대로 사용해서 중요도를 더하면, \((+0.5) + (-0.5) = 0\)이 되어버려서 ’환불’과 ’배송’이 하나도 안 중요한 단어처럼 보이는 심각한 왜곡이 발생.
    • 따라서 방향은 상관없이 순수하게 축 형성에 기여한 힘(중요도)을 볼 때는 가중치의 제곱(또는 절댓값)을 사용.
      • ’환불’의 순수 기여도: \(\propto (0.5)^2 = 0.25\).
      • ’배송’의 순수 기여도: \(\propto (-0.5)^2 = 0.25\).
      • ’입금’의 순수 기여도: \(\propto (0.4)^2 = 0.16\).
      • ’출발’의 순수 기여도: \(\propto (-0.4)^2 = 0.16\).

C. 표현품질(Cos2)

  • 구현 함수
    • fviz_pca_ind(): 해석의 ‘신뢰도’ 평가.
    • 개념: “2D 플롯에 찍힌 이 점(문서)이, 원본(수만 개 차원)의 정보를 얼마나 잘 보존하고 있는가?”
  • 사영(projection)의 문제
    • 우리의 원본 TF-IDF 행렬은 수천 개의 차원으로 이루어진 초고차원 공간(hyperspace)에 존재함.
    • 우리가 2D(PC1, PC2)로 시각화하는 것은, 이 수만 개 차원의 데이터를 2D 평면에 ’사영’시키는 것과 같음.
    • 비유: 3D 공간에 떠 있는 구체를 손전등으로 벽(2D)에 비춰 ’그림자’를 만드는 것과 같음. 이 ’그림자’가 바로 2D 플롯임.
  • 문제점(정보의 손실)
    • 왜곡: 손전등을 구체의 정면에서 비추면 그림자가 원 모양으로 잘 보이지만, 모서리([EX] PC3, PC4 방향)에서 비추면 얇은 타원이나 선으로 찌그러져 보임.
    • 겹침: 치명적 문제! 3D 공간에서는 멀리 떨어져 있던 ’구체의 앞면’에 있는 점과 ’뒷면’에 있는 점이, 2D 그림자(플롯) 위에서는 서로 겹쳐 보일 수 있음.
    • 결론: 2D 플롯에서 두 문서(점)가 가깝게 붙어 있다고 해서 “이 두 문서는 주제가 비슷하다!”라고 성급히 결론 내릴 수 없음. 실제(초고차원)로는 전혀 다른 주제([EX] 한 점은 PC3의 +방향, 다른 점은 PC3의 -방향에 있음)일 수 있기 때문임.
  • 해결책(Cos2)
    • Cos2(Squared Cosine, 0~1): “이 그림자(2D 플롯의 점)가 얼마나 원본(초고차원 공간의 점)의 정보를 잘 보존하고 있는가?”를 나타내는 ’품질점수’임.
    • 해석:
      • \(Cos2 \approx 1\)(색이 진함): 정보 보존율 100%. “이 점의 2D 위치는 신뢰할 수 있음.”
      • \(Cos2 \approx 0\)(색이 옅음): 정보 보존율 0%. “이 점은 사실 PC3, PC4… 등 ’보이지 않는 축’에 정보가 있으며, 현재 2D 플롯의 위치는 아무 의미 없음.”
      • 최종 활용: 따라서 PCA 플롯을 해석할 때는, 반드시 Cos2 값이 높은(색이 진한) 점이나 단어들을 중심으로 의미를 해석해야 함.

(2) FactoMineR 패키지

1) PCA()

  • 핵심기능: 주성분 분석(PCA)을 실행하고, 동시에 ‘기여도(Contribution)’, ‘표현품질(Cos2)’ 등 해석에 필요한 모든 보조지표를 함께 계산함.
  • 수업에서의 활용: R 기본함수 prcomp()가 단순 ’계산’에 중점을 둔다면, PCA()는 앞서 이론 파트에서 다룬 ’해석’에 필요한 모든 자료를 한 번에 생성해줌. tfidf_matrix를 입력받아 PCA 결과 객체를 생성함.
  • 주요 논항
    • X: 숫자 행렬 또는 데이터프레임(본 수업에서는 tfidf_matrix가 입력됨).
    • scale.unit: TRUE로 설정 시, PCA 수행 전 모든 변수(단어)를 평균 0, 분산 1로 표준화(scaling)함. TF-IDF 행렬이라도 각 단어(열)의 분산이 다를 수 있으므로, 모든 단어가 동등한 영향력을 갖도록 TRUE로 설정하는 것이 일반적임.
    • ncp: 보존(계산)할 주성분의 최대 개수(기본값 5).
    • graph: TRUE로 설정 시 FactoMineR 고유의 그래프를 출력함. 우리는 factoextraggplot2 기반 그래프를 사용할 것이므로 FALSE로 설정함.

(3) factoextra 패키지

1) fviz_eig()

  • 핵심기능: PCA 결과의 ’고윳값(eigenvalue)’을 시각화함. 이것이 바로 scree 플롯임.
  • 수업에서의 활용: scree 플롯을 그려, 각 PC(주성분)가 원본 데이터의 분산(정보)을 몇 %나 설명하는지 한눈에 파악함(몇 개의 PC를 사용할지 결정하는 데 사용).
  • 주요 논항
    • X: PCA() 결과 객체(본 수업에서는 pca_result가 입력됨).
    • addlabels: TRUE로 설정 시 막대 위에 분산(%) 값을 텍스트로 표시함.
    • ncp: 상위 몇 개의 PC까지 그래프에 그릴지 지정함([EX] 10).

2) fviz_contrib()

  • 핵심기능: PCA 축(PC)에 ’기여(Contribution)’한 변수(단어)들을 막대 그래프로 시각화함.
  • 수업에서의 활용
    • 앞서 다룬 PC 축에 이름 붙이기를 수행하는 핵심 함수.
    • 어떤 단어들이 PC1 또는 PC2의 정체성을 정의하는지(즉 가중치/로딩 값이 큰지) 한눈에 보여줌.
  • 주요 논항
    • X: PCA() 결과 객체(pca_result).
    • choice: "var"(변수/단어의 기여도) 또는 "ind"(개별 문서의 기여도)를 볼지 선택.
    • axes: 기여도를 확인할 PC 축 번호(정수)([EX] 1, 2)
    • top: 기여도가 가장 높은 상위 몇 개만 표시할지 지정함. ([EX] 10)

3) fviz_pca_var()fviz_pca_ind()

  • 핵심기능: PCA 2D 공간(PC1-PC2 평면)에 변수(_var) 또는 개별 관측치(_ind)를 시각화함.
  • 수업에서의 활용
    • fviz_pca_var(): 단어들 간의 관계([EX] ’배송’과 ’출발’이 같은 방향인지)와 축 기여도를 한눈에 봄.
    • fviz_pca_ind(): 문서들의 분포를 ’의미지도’처럼 시각화함.

A. fviz_pca_var() 함수(변수/단어 플롯) 주요 논항

  • X: PCA() 결과 객체(pca_result).
  • col.var: 변수(단어)의 색상을 무엇을 기준으로 칠할지 지정.
    • "contrib": 기여도(축 형성에 중요한 단어 강조).
    • "cos2": 표현품질(2D 평면에 잘 표현된 단어 강조).
  • repel: TRUE로 설정 시 텍스트가 겹치지 않게 함(필수).

B. fviz_pca_ind()(개별/문서 플롯) 주요 논항

  • X: PCA() 결과 객체(pca_result).
  • col.ind
    • 개별 문서(점)의 색상을 무엇을 기준으로 칠할지 지정.
    • "cos2": 표현품질(2D 위치가 신뢰할 만한지 평가).
  • habillage
    • 최종 검증용!
    • 정답 레이블 팩터(true_topic_factor)를 이 논항에 전달하면, 실제 주제별로 점의 색상을 다르게 칠해줌.
  • addEllipses: TRUE로 설정 시 habillage로 칠해진 그룹별로 타원을 그려줌.

(4) 실습

1) PCA 실행 및 scree 플롯 그리기

A. PCA 실행

load("pca_result.rda")

# 관련 패키지 로드
library(FactoMineR) # PCA() 함수 제공.

# PCA 실행
# FactoMineR 패키지의 PCA() 함수를 사용하여 PCA 실행.
pca_result <- PCA(
    tfidf_matrix , # 입력 데이터(숫자 행렬): 기본 매트릭스 적용.
    scale.unit = FALSE, # 이미 앞서 tfidf_matrix 생성 시 표준화를 적용했으므로 다시 표준화를 적용할 필요 없음.
    graph = FALSE # FactoMineR 자체 그래프는 끔(factoextra 사용).
)
save(pca_result, file="pca_result.rda")

B. scree 플롯 그리기

# scree 플롯 시각화
# fviz_eig: PCA 결과(pca_result)의 고윳값(eigenvalue)을 시각화.
uns_eig <- fviz_eig(
  pca_result, # PCA 결과 객체.
  addlabels = TRUE, # 막대 위에 분산(%) 표시.
  ncp = 7 #상위 7개 PC만 표시.
  )
ggsave("uns_eig_plot.png", uns_eig, width = 10, height = 10)
uns_eig

C. scree 플롯 해석

  • 플롯의 목적: PCA가 생성한 새로운 축(dimensions, 즉 주성분)들이 각각 원본 데이터의 전체 정보(분산)를 몇 %나 설명하는지 보여줌.
  • 관찰
    • Dim 1(PC1, x축)이 전체 분산의 0.6%를 설명함.
    • Dim 2(PC2, y축)가 전체 분산의 0.4%를 설명함.
    • Dim 3이 0.4%, Dim 4가 0.3%로, 설명력이 매우 느리고 완만하게 감소함(긴 꼬리[long tail]).
  • 해석
    • PC1과 PC2를 합쳐도 우리가 2D 플롯에서 볼 수 있는 정보는 전체의 1.0(0.6 + 0.4)%에 불과함.
    • 이는 TF-IDF 행렬이 수천 개의 차원(단어)을 가지며, 정보가 이 모든 차원에 매우 얇고 넓게(희소하게) 분산되어 있음을 의미함(“차원의 저주”).
  • 결론: 이후에 보게 될 uns_pca_va_plot.png(변수 플롯)과 uns_pca_ind_plot.png(개별 문서 플롯)은 데이터의 1%만을 보여주는 극히 일부의 ’그림자’임을 인지해야 함. 이 2D 플롯에서 겹쳐 보인다고 해서, 실제 수천 차원의 공간에서 겹쳐 있다고 단정할 수 없음.

2) 변수(단어) 해석: PC 축에 ‘이름 붙이기’

A. 단어 기여도 시각화

  • PC1은 무슨 ’의미축’일까?
# 단어 기여도 시각화(PC1)
# fviz_contrib(): PCA 축 형성에 '기여'한 변수(단어)들을 시각화해줌.
uns_pc1_contrib <- fviz_contrib(pca_result, # PCA 결과 객체.
                                choice = "var", # '변수(variable)'의 기여도를 보겠음(반대: "ind").
                                axes = 1, # 'PC1' 축에 대한 기여도.
                                top = 10  # 기여도가 가장 높은 상위 10개 단어만 표시.
                                )
ggsave("uns_pc1_contrib_plot.png", uns_pc1_contrib, width = 10, height = 10)

[그림 9] 단어 기여도 시각화(PC1)

  • PC2는 무슨 ’의미축’일까?
# 단어 기여도 시각화(PC2)
uns_pc2_contrib <- fviz_contrib(pca_result, 
                                choice = "var", 
                                axes = 2, 
                                top = 10)
ggsave("uns_pc2_contrib_plot.png", uns_pc2_contrib, width = 10, height = 10)

[그림 10] 단어 기여도 시각화(PC2)

B. 단어 기여도 해석

가. PC1 기여도 해석
  • 플롯의 목적: 전체 분산의 0.6%를 설명하는 Dim 1(PC1, x축)의 정체성을 정의하는 데 어떤 피처(단어)가 가장 크게 기여했는지 보여줌.
  • 관찰: ‘거’, ‘기사’, ‘방문’, ‘한’, ‘안’, ‘점검’, ‘같’, ‘분’, ‘번’, ‘출장’ 순서로 PC1 형성에 높은 기여도(Contribution)를 보임.
  • 해석
    • 이 단어들은 특정 주제와 강하게 결부되어 있음. 기사(technician), 방문(visit), 점검(inspection), 출장(business trip), (e.g., 작동이 ‘안’ 됨) 등은 모두 “AS(수리/고장)” 상황과 직접적으로 관련된 어휘임.
    • , , , 등은 토크나이저가 분리한 형태소로, ‘고장 난 거 같아요’, ’한 번 봐주세요’처럼 AS 관련 맥락에서 자주 등장하여 함께 뽑힌 것으로 보임.
  • 결론: PC1은 “AS/기사방문” 축으로 명명할 수 있음. 이 축은 ‘기사/방문’ 관련 어휘가 많이 등장하는 문서와 그렇지 않은 문서를 구분하는, 데이터 전체에서 가장 분산이 큰(가장 큰 차이를 보이는) 축임.
나. PC2 기여도 해석
  • 플롯의 목적: 전체 분산의 0.4%를 설명하는 Dim 2(PC2, y축)의 정체성을 정의하는 데 어떤 피처가 가장 크게 기여했는지 보여줌.
  • 관찰: ‘수집’, ‘만족도’, ‘조사’, ‘개인’, ‘동의’, ‘방침’, ‘취급’, ‘제공’, ‘점검’, ‘시’ 순서로 높은 기여도를 보임.
  • 해석
    • PC1과는 다른 주제의 단어들이 포착됨.
    • 만족도, 조사, 개인, 동의, 제공, 취급, 방침: 이는 상담원이 고객에게 ’개인정보 동의’를 구하거나, ’만족도 조사’를 안내하는 등의 상담 후속조치 및 정보처리 맥락의 어휘임.
  • 결론: PC2는 “상담/정보처리” 축으로 명명할 수 있음. 이는 “AS/기사방문” 다음으로(PC1 다음으로) 데이터의 분산을 많이 설명해주는 두 번째 축임.

3) 변수 상관 원형 플롯(variables plot)

A. 변수(단어) 간 관계 시각화

  • PC 공간에서 변수(단어)들이 가지는 방향과 관계를 시각화하기.
# 변수(단어) 상관 원형 플롯
uns_pca_va <- fviz_pca_var(pca_result,
                           col.var = "contrib", # 단어 색상을 '기여도'에 따라 매핑.
                           gradient.cols = c("#00AFBB", "#E7B800", "#FC4E07"), # 기여도 색상표.
                           repel = TRUE # 단어 텍스트가 겹치지 않도록 함.
                           )
ggsave("uns_pca_va_plot.png", uns_pca_va, width = 10, height = 10)

[그림 11] 변수 상관 원형 플롯

B. 변수 상관 원형 플롯 해석

  • 플롯의 목적: PC1(x축)과 PC2(y축)가 만드는 2D 평면에서, 수천 개의 모든 피처(단어)들이 어느 방향으로, 얼마나 강하게 위치하는지 보여줌.
  • 관찰(observation)
    • 대부분의 피처(하늘색)는 원점(0,0) 근처에 빽빽하게 뭉쳐 있음(성게 또는 빅뱅 형태).
    • “contrib” 범례에 따라, 주황색/노란색으로 표시된 ‘기여도가 높은’ 소수의 피처들만 원점에서 멀리 뻗어나감.
    • 주황색/노란색 화살표는 주로 오른쪽-아래(4사분면)와 왼쪽-위(2사분면) 두 방향으로 나뉘어 있음.
  • 해석(interpretation)
    • PC1 기여도 플롯에서 기사, 방문 등이 PC1의 핵심임을 확인했음. 이 플롯에서 주황색 화살표가 뻗어나간 오른쪽-아래(PC1+, PC2-) 영역이 바로 “AS/기사방문”의 의미영역임.
    • PC2 기여도 플롯에서 수집, 만족도 등이 PC2의 핵심임을 확인했음. 이 플롯에서 주황색 화살표가 뻗어나간 왼쪽-위(PC1-, PC2+) 영역이 바로 “상담처리”의 의미영역임.
  • 결론: 우리가 보는 1%의 2D 평면은, 데이터의 수많은 주제 중 오직 “AS/기사방문”(우하단)과 “상담처리”(좌상단)라는 두 개의 극단적인 주제 사이의 줄다리기로만 구성되어 있음을 보여줌.

4) Biplot 및 통합해석: 정답 레이블로 검증 및 위치 신뢰도 평가

A. 정답 레이블 검증 시각화하기

  • PCA가 만든 의미공간에 실제 주제(‘정답’)를 색칠해보기.
# '정답' 레이블 로드 및 변환
# docvars()로 Corpus의 'true_topic'을 가져와 factor(범주형)로 변환.
true_topic_factor <- as.factor(docvars(uns_corpus, "true_topic"))

# 개별 문서(관측치) 플롯 + 정답 색칠
# fviz_pca_ind(): PC 공간에서 개별 문서(individual)들의 위치를 시각화.
uns_pca_ind <- fviz_pca_ind(pca_result,
                            geom.ind = "point", # 문서를 '점'으로 표시.
                            habillage = true_topic_factor, # (핵심) '정답' 주제별로 점 색깔을 다르게 칠함.
                            addEllipses = TRUE, # 같은 주제의 점들 주위에 타원을 그림.
                            legend.title = "True Topics", # 범례 제목.
                            ggtheme = theme_minimal() # 그래프 테마.
                            )
ggsave("uns_pca_ind_plot.png", uns_pca_ind, width = 10, height = 10)

[그림 12] 정답 레이블 검증 시각화

B. 정답 레이블 검증 결과 해석

  • 플롯의 목적: 변수 플롯이 만든 2D 지도 위에, 개별 문서들(individuals)이 어디에 위치하는지 점으로 찍고, 우리가 가진 ’실제 정답(True Topics)’으로 색칠한 플롯임.
  • 관찰(observation)
    • AS문의(빨간색): 플롯 전체에 넓게 퍼져 있으나, 변수 플롯에서 “AS/기사방문” 영역으로 확인된 오른쪽-아래(PC1+, PC2-) 방향으로 강하게 쏠려 있음. 이 주제의 타원(ellipse)이 가장 큼.
    • 나머지 4개 주제: 배송(초록색), 제품/사용문의(보라색), 주문/결제(하늘색), 환불/반품(분홍색)은 모두 서로 거의 구분이 불가능하게 겹쳐진 채 “상담처리” 영역인 왼쪽-위(PC1-, PC2+)에 거대한 덩어리를 이루고 있음.
  • 해석(interpretation)
    • 이 플롯은 하이브리드 군집 분석(k=5)이 왜 실패했는지(왜 ’쓰레기통 군집’이 생겼는지)를 시각적으로 완벽하게 증명함.
    • PCA가 1%의 정보로 볼 때, 이 데이터는 5개 주제가 아니라 오직 두 개의 덩어리로만 구성되어 있음. \(\Rightarrow\) “AS문의” 덩어리(빨간색) + “AS가 아닌 나머지 모든 것” 덩어리(4개 색깔이 겹친 좌상단).
  • 결론: PAM 알고리즘 역시 이 2D 플롯과 동일한 구조를 보았음. AS문의는 독자적인 신호(PC1)가 있어 분리하려 했으나, 나머지 4개 주제는 피처(단어) 상으로 볼 때 서로 너무 유사하여 구분할 수 없었기 때문에 하나의 거대군집으로 묶어버린 것임. 이는 모델의 실패가 아니라, 5개 주제를 구분하기에는 피처의 표현력이 근본적으로 부족하다는 데이터의 한계를 보여주는 ’발견’임.

C. 위치 신뢰도 시각화하기

  • 2D 평면상에 찍힌 점들의 위치 신뢰도를 시각화하기.
# '정답' 레이블 로드 및 변환
# docvars()로 Corpus의 'true_topic'을 가져와 factor(범주형)로 변환.
true_topic_factor <- as.factor(docvars(uns_corpus, "true_topic"))

# 개별 문서(관측치) 플롯 + 정답 색칠
# fviz_pca_ind(): PC 공간에서 개별 문서(individual)들의 위치를 시각화.
uns_pca_ind_cos2 <- fviz_pca_ind(pca_result,
                                 geom.ind = "point", # 문서을 점으로 표시.
                                 # 'habillage' 대신 'col.ind'(individual color)를 'cos2' 값으로 지정.
                                 col.ind = "cos2",
                                 # 품질이 높을수록(1에 가까움) 밝은 색([EX] 노랑/빨강)이 됨.
                                 gradient.cols = c("#00AFBB", "#E7B800", "#FC4E07"), 
                                 legend.title = "Cos2 (표현 품질)", # 범례 제목.
                                 ggtheme = theme_minimal() # 그래프 테마.
                                 )
ggsave("uns_pca_ind_cos2_plot.png", uns_pca_ind_cos2, width = 10, height = 10)
uns_pca_ind_cos2

D. 위치 신뢰도 평가 해석

  • 이 플롯은 “이 2D 지도의 어느 부분을 믿어야 하는가?”라는 질문에 답을 줌.
  • 색상의 의미
    • 파란색/청록색(Cos2 ≈ 0.1): “신뢰도 낮음”. 이 점의 2D 위치는 실제 정보를 거의 반영하지 못함(정보의 90%가 Dim3, Dim4…에 있음).
    • 주황색/빨간색(Cos2 ≈ 0.3+): “신뢰도 높음”. 이 점의 2D 위치는 실제 정보를 비교적 잘 반영함(정보의 30% 이상이 Dim1/Dim2에 있음).
가. 좌상단(PC1-, PC2+): “신뢰할 수 없는 거대 덩어리”
  • 관찰: 플롯의 좌상단, 즉 (0,0) 원점 근처에 파란색/청록색(낮은 Cos2)의 점들이 거대하게 뭉쳐 있음.
  • 이전 플롯과의 연결: 이 위치는 uns_pca_ind_plot.png(habillage 플롯)에서 AS문의를 제외한 나머지 4개 주제(배송, 제품, 주문, 환불)가 서로 겹쳐 있던 바로 그 ‘쓰레기통 군집’ 영역임.
  • 해석
    • Cos2 플롯은 이 4개 주제가 왜 겹쳐 보였는지에 대한 결정적인 이유 제시. \(\Rightarrow\) 이 문서들은 Cos2 값이 극도로 낮기(파란색) 때문!
    • 이 4개 주제의 문서들은 PC1(‘AS축’)이나 PC2(‘상담축’)의 특성을 거의 가지고 있지 않음. \(\Rightarrow\) 이 문서들의 진짜 정보는 우리가 못 보는 99%의 다른 차원(Dim3, Dim4…)에 숨어 있음.
  • 결론: 이 좌상단의 덩어리는 “해석해서는 안 되는 영역”임. 이 2D 플롯은 이 4개 주제의 특성을 전혀 표현하지 못하고 있음.
나. 우하단(PC1+, PC2-): “신뢰할 수 있는 소수의 신호”
  • 관찰: 플롯의 우하단, 즉 (0,0) 원점에서 멀리 떨어진 곳에 주황색/빨간색(높은 Cos2)의 점들이 드문드문 흩어져 있음.
  • 이전 플롯과의 연결: 이 위치는 habillage 플롯에서 AS문의(빨간색 점)가 주로 분포하던 영역이며, 변수 플롯에서 ‘기사’, ‘방문’ 단어가 위치했던 “AS 축”의 방향임.
  • 해석
    • 이 플롯은 AS문의 주제에 속한 문서들이 PC1(AS 축)의 특성을 매우 강력하고 순수하게 가지고 있음을 증명.
    • 이 주황색/빨간색 점들은 PC1/PC2라는 2D 평면만으로도 그 특성이 30% 이상 설명되는 “핵심신호” 문서들임.
  • 결론: 이 2D 플롯에서 우리가 유일하게 믿고 해석할 수 있는 영역은 바로 이 우하단의 AS문의 관련 문서들임.
다. 요약 및 결론
  • 이 Cos2 플롯은 우리가 habillage 플롯(uns_pca_ind_plot.png)만 보고 섣불리 결론 내리는 것을 막아줌.
  • habillage 플롯: “AS문의는 분리되고, 나머지 4개는 겹친다”(WHAT)는 현상을 보여줌.
  • Cos2 플롯: “AS문의는 이 2D 평면으로 설명이 잘 되고(HOW MUCH), 나머지 4개는 이 2D 평면으로는 전혀 설명이 안 된다”(WHY)는 이유와 신뢰도를 보여줌.
  • 결론: 이 2D PCA 분석은 5개 주제 중 AS문의의 독특성을 밝혀내는 데는 성공했지만, 나머지 4개 주제를 구분하는 데는 실패했음. 그 이유는 이 4개 주제의 정보가 PC1/PC2에 담겨 있지 않기 때문!

4. 요약

(1) 핵심요약(take-home message)

  • NLP 비지도학습의 첫걸음: 텍스트를 숫자로! \(\Rightarrow\) 대규모 데이터는 quanteda로 DFM/TF-IDF 생성하기!
  • 문서 군집화(PAM): ’비슷한 주제’의 문서들을 그룹으로 묶음(이상치에 강건).
    • 기법 비교: K-Means(빠름, 이상치 취약) vs. K-Medoids(이상치에 강건, 해석 용이).
    • 객관적 K 선택: 실루엣 계수(\(a\): 응집도, \(b\): 분리도).
    • 해석: table(True, Model)을 통해 ’정답’과 비교 검증함.
  • 차원 축소(PCA): 수천 개 단어들을 2~3개의 ’의미축’으로 요약함(연속적 스펙트럼).
    • 핵심개념: 분산보존, 축 간 직교성(독립성), 가중치 조합(레시피), 고윳값 분해(뼈대 찾기).
    • 해석(신뢰도): Cos2(2D 플롯에 잘 표현되었나?)
    • 해석(명명): 기여도(Contribution)를 보고 축의 이름([EX] “배송”, “교환/환불”)을 명명함.
    • 해석(검증): habillage 옵션으로 ’실제 주제’와 PCA 공간이 일치하는지 확인함.
  • 우리의 발견
    • 우리는 pam()이라는 망치를 들고, 5개의 ‘못’(True Topics)을 박으려고 했음. 그런데 Silhouette이라는 계측기가 군집을 나누는 것이 거의 무의미하다며 경고를 보냈음. 게다가 PCA라는 X-ray를 찍어보니, 벽 안에 5개의 못 구멍이 있는 게 아니라 AS문의라는 못 구멍 하나와 `나머지 4개가 뭉쳐진 거대한 쇠판’이 있다는 것을 발견했음.
    • 우리의 발견은 ’5개의 군집’이 아니라, 현재 피처로는 4개의 주제를 구분할 수 없다는 것. 이것이 비지도학습!

(2) 심화토론

  • 심화토론: 여러분의 연구 데이터에 오늘 배운 기법을 어떻게 적용할 수 있을까?
  • 예시: 질적 연구를 위한 “인터뷰 스크립트”(100명) 분석.
  • 문제: 질적 연구자가 100명의 인터뷰 스크립트를 수동으로 코딩(범주화)하는 것은 매우 주관적이고 시간이 오래 걸림.

1) K-Medoids(PAM) 적용

  • 이번 수업에서 배운 기법은 ’데이터 기반(data-driven)’의 객관적 범주화를 도와줌.
  • K-Medoids(PAM) 적용: “응답자 유형(archetype) 발견”
    • 목표: 100명의 응답자를 비슷한 답변 패턴을 가진 그룹으로 묶기.
    • 입력: 100개의 인터뷰 스크립트 TF-IDF 행렬.
    • 실행: factoextra 패키지의 fviz_nbclust()로 최적의 K 탐색([EX] K=4). \(\Rightarrow\) cluster 패키지의 pam() 실행.
    • 해석: 4개의 군집이 발견됨.
      • Cluster 1: ‘직무경험’, ‘성과’, ‘프로젝트’ 단어 위주. \(\Rightarrow\) “경험 중심형.”
      • Cluster 2: ‘소통’, ‘팀워크’, ‘협력’, ‘갈등해결’ 단어 위주. \(\Rightarrow\) “관계 중심형.”
      • Cluster 3: ‘열정’, ‘노력’, ‘성장’, ‘배우다’ 단어 위주. \(\Rightarrow\) “태도 중심형.”
      • Cluster 4: … (기타).
    • 활용: 연구자는 이 네 가지 유형을 객관적인 ’응답자 유형’으로 정의하고, 각 유형의 대표 문서(medoid)를 심층 분석할 수 있음.

2) PCA 적용

  • PCA 적용: “핵심 의미축(spectrum) 발견”
    • 목표: 100명의 응답을 가로지르는 핵심적인 ‘의미 스펙트럼’ 찾기.
    • 입력: 동일한 TF-IDF 행렬.
    • 실행: FactoMineR 패키지의 PCA() 실행.
    • 해석:
      • fviz_contrib() 확인. \(\Rightarrow\) PC1(Dim1)은 ‘경험’과 ’성과’(+방향) vs. ‘열정’과 ’태도’(-방향) 단어들이 크게 기여. \(\Rightarrow\) PC1을 “경력/신입 스펙트럼”으로 명명.
      • fviz_contrib() 확인. \(\Rightarrow\) PC2(Dim2)는 ‘개인’과 ’역량’(+방향) vs ‘팀’과 ’소통’(-방향) 단어들이 크게 기여. \(\Rightarrow\) PC2를 “개인/조직 지향 스펙트럼”으로 명명.
    • 활용: fviz_pca_ind()로 100명의 응답자를 2D 의미공간에 시각화.
  • 심화: 만약 ‘합격/불합격’ 여부(true_outcome) 정보를 docvars로 가지고 있다면, habillage = true_outcome을 통해 합격자들이 특정 사분면([EX] ’경력’과 ’조직 지향’이 모두 높은 곳)에 밀집하는지 시각적으로 검증할 수 있음.